Cải thiện JS Performance với Object Pool Pattern

Hà Nội, Discovery Complex 302 Cầu Giấy,

Một ngày có nắng nhưng vẫn lạnh. Tâm trạng hơi chán. Thôi viết blog vừa để chạy deadline, vừa để cho chán hơn vậy 😀

Hôm nay mình sẽ cùng các bạn đi vào tìm hiểu một pattern trong vô vàn hằng hà sa số những design pattern, và ứng dụng thực tiễn của nó, để các bạn tin rằng nó có tác dụng trong thực tế, chứ không phải lý thuyết suông (mặc dù mình cũng chưa từng áp dụng nó).

Vấn đề

Trong Javascript, việc cấp phát bộ nhớ (Memory allocation) hầu như là một việc tiêu tốn nhiều thời gian, và có thể rất chậm. Code của chúng ta càng cấp phát ít bộ nhớ, thì càng tốt. Cấp phát bộ nhớ có thể làm cho code của chúng ta chạy chậm hơn khá nhiều – tầm 3-4 lần. Nhưng việc phân bổ bộ nhớ chỉ là một phần của vấn đề. Khi mà chúng ta cần xoá một object trong JavaScript, chúng thực chất vẫn còn trong bộ nhớ cho đến khi chúng được giải phóng. Cơ chế mà chịu trách nhiệm việc giải phóng bộ nhớ này được gọi là Garbage Collection (GC). JavaScript Engine sẽ có nhiệm vụ theo dõi các biến đã xoá và xoá chúng khỏi bộ nhớ. Ở trên là đoạn giải thích đã được rút ngắn lại hết sức có thể để miêu tả về GC, nếu anh chị em nào có hứng thú đọc sâu hơn, có thể tham khảo blog này.

Okeee, vậy JS Engine đã chịu trách nhiệm làm việc này cho chúng ta. Vậy chúng ta cần gì phải quan tâm ?

Để miêu tả vấn đề của GC, chúng ta hãy xem đoạn code dưới đây:

class Boom {
    constructor(id) {
        this.id = id;
    }
    setPosition(x, y) {
        this.x = x;
        this.y = y;
    }
}
function testWithNoPool() {
    const bombs = 1000; // create 1000 bombs on every run
    const bulks = 1000; // create and destroy a 1000 times
    const repeats = 50;
    function noPool() {
        const booms = [];
        for (let i = 0; i < bulks; i++) {
            for (let boom = 0; boom < bombs; boom++) {
                const currBoom = booms[booms.push(new Boom()) - 1];
                currBoom.setPosition(Math.random(), Math.random());
            }
        }
    }
    function repeat(cb) {
        console.log('%c Started test with no pool', 'color: red');
        const start = performance.now();

        for (let i = 0; i < repeats; i++) {
            cb();
        }

        const end = performance.now();
        console.log('%c Finished test with no pool, run in ', 'color: red', ((end - start) / 1000).toFixed(4), ' seconds');
        return end - start;
    }
    repeat(noPool);
}

Nếu chúng ta đo thời gian chạy của đoạn code này bằng tab Performance trên Chrome Dev Tools, sẽ thấy một vài điều khá là thú vị :

Hình ở trên miêu tả thời gian chạy dưới dạng bottom-up. Như chúng ta có thể thấy, GC tiêu tốn mất hơn 4s cuộc đời của chúng ta, chiếm ~65% thời gian chạy. Nếu chúng ta có thể cải thiện, vậy thì quá tốt rồi !

Vậy điều gì gây ra quá trình GC này ?

Khi chúng ta tạo ngày càng nhiều object trong vòng lặp for, JSEngine sẽ lưu trữ chúng trong bộ nhớ. Sau đó chúng ta xoá mảng (set objs.length = 0).

JSEngine xem dữ liệu bị loại bỏ như một mục tiêu để xử lý (GC). Sau đó, chúng ta tạo các object mới trong mảng, lúc đó JSEngine lại phân bổ bộ nhớ mới cho chúng.

Khi chúng ta lặp lại quá trình đó, JSEngine cũng lặp lại quá trình GC. Hơi tệ.

Cách giải quyết bằng Object Pool Pattern

Object Pool Pattern cung cấp một kỹ thuật để tái sử dụng objects thay vì khởi tạo không kiểm soát.

Ý tưởng: dùng Object Pool Pattern quản lý một tập hợp các objects mà sẽ được tái sử dụng trong chương trình. Khi client cần sử dụng object, thay vì tạo ra một đối tượng mới thì client chỉ cần đơn giản yêu cầu Object pool lấy một đối tượng đã có sẵn trong object pool. Sau khi object được sử dụng nó sẽ không hủy mà sẽ được trả về pool cho client khác sử dụng. Nếu tất cả các object trong pool được sử dụng thì client phải chờ cho tới khi object được trả về pool.

Cách hoạt động: tự tạo đối tượng mới nếu chưa có sẵn hoặc khởi tạo trước 1 object pool chứa một số đối tượng hạn chế trong đó.

Client : một class yêu cầu khởi tạo đối tượng PooledObject để sử dụng.
PooledObject : một class mà tốn nhiều thời gian và chi phí để khởi tạo. Một class cần giới hạn số lượng đối tượng được khởi tạo trong ứng dụng.
ObjectPool : đây là lớp quan trọng nhất trong Object Pool Pattern. Lớp này lưu giữ danh sách các PooledObject đã được khởi tạo, đang được sử dụng. Nó cung cấp các phương thức cho việc lấy đối tượng từ Pool và trả đối tượng sau khi sử dụng về Pool.

Áp dụng vào bài toán phía trên

class PooledObject {
    constructor(data) {
        this.data = data;
        this.nextFree = null;
        this.previousFree = null;
    }
}

class Pool {
    constructor(objCreator, objReseter, initialSize = 5000) {
        this._pool = [];
        this.objCreator = objCreator;
        this.objReseter = objReseter;
        for (let i = 0; i < initialSize; i++) { 
            this.addNewObject(this.newPoolObject()); 
        }
    }
    addNewObject(obj) {
        this._pool.push(obj);
        this.release(obj);
        return obj;
    }
    release(poolObject) {
        // flag as free
        poolObject.free = true;

        // set in the dequeue
        poolObject.nextFree = null;
        poolObject.previousFree = this.lastFree;

        // if we had a last free, set the last free's next as the new poolObject
        // otherwise, this is the first free!
        if (poolObject.previousFree) {
            this.lastFree.nextFree = poolObject;
        } else {
            this.nextFree = poolObject;
        }

        // set the new object as the last in the dequeue
        this.lastFree = poolObject;

        // reset the object if needed
        this.objReseter(poolObject);
    }

    getFree() {
        // if we have a free one, get it - otherwise create it
        const freeObject = this.nextFree ? this.nextFree : this.addNewObject(this.newPoolObject());

        // flag as used
        freeObject.free = false;

        // the next free is the object's next free
        this.nextFree = freeObject.nextFree;

        // if there's nothing afterwards, the lastFree is null as well
        if (!this.nextFree) this.lastFree = null;

        // return the now not free object
        return freeObject;
    }

    newPoolObject() {
        const data = this.objCreator();
        return new PoolObject(data, this.lastFree, this.nextFree);
    }

    releaseAll() {
        this._pool.forEach(item => this.release(item));
    }
}

Trong đoạn code solution ở trên, chúng ta luôn giữ nextFree của pool làm tham chiếu đến object free có sẵn tiếp theo. Sau khi chúng ta tìm nạp nó, chúng ta đặt nextFree là phần tiếp theo của nextFree, và đó là cách hàng đợi được tiếp tục.

Và woww, tác vụ của GC chỉ chiếm 91.1 ms, gần như là không còn gì cả.

Tối ưu hoá số lượng phần tử trong pool

Như chúng ta có thể thấy, số thực thể được phân bố trước, mặc định là 5000. Có hai câu hỏi được đặt ra:

  • Chúng ta nên phân bố trước bao nhiêu thực thể ?
  • Điều gì sẽ xảy ra khi chúng ta đạt đến con số này ?

Câu hỏi đầu tiên khá dễ để trả lời, chúng ta nên biết rõ về app của mình, và ước tính dữ liệu mà chúng ta sẽ giữ.

Câu hỏi thứ hai có một số cách để thực hiện. Trong đoạn code trên, tại thời điểm chúng ta không còn object nào free, chúng ta sẽ tạo một object mới, và nó sẽ ở đó mãi mãi. Mặt khác, chúng ta có thể quyết định một ngưỡng (ví dụ luôn có ít nhất 2% số object free). Mỗi khi chương trình vượt qua ngưỡng đó, chúng ta có thể tăng gấp đôi số lượng object chúng ta đang có, hoặc tăng thêm 100, nói chung là chúng ta sẽ thay đổi theo cách phù hợp

Tổng kết

Khi mình viết đến những dòng tổng kết này, là đã đến 2 ngày hôm sau. Đợt này đang ốm quá nên nợ các bạn phần tổng kết nha. Thân ái, chào tạm biệt và hẹn gặp lại vào một ngày không xa ! À quên, nếu ai đang làm ở Flinters thì có thể tham khảo code ở ví dụ trong đây 😀 Bye bye !

Add a Comment

Scroll Up