JavaScript: cơ chế thu hồi rác

JavaScript: cơ chế thu hồi rác
Photo by Pawel Czerwinski from Unsplash

Trong bài viết này, tôi sẽ trình bày những hiểu biết của mình về một trong những cơ chế hoạt động bên trong của JavaScript engine – cơ chế thu hồi rác.

Cơ chế thu hồi rác (garbage collection – GC) luôn là một phần quan trọng của bất kỳ ngôn ngữ lập trình nào. Với những ngôn ngữ hướng hệ thống như C hay C++, việc này phải được thực hiện thủ công. Nhưng JavaScript và nhiều ngôn ngữ khác đã tự động hóa việc này.

Vòng đời của bộ nhớ trong JavaScript

Vòng đời của bộ nhớ trong hầu hết các ngôn ngữ lập trình đều tương tự nhau, bao gồm các bước nhau:

  • cấp phát bộ nhớ
  • sử dụng bộ nhớ được cấp phát (đọc/ghi)
  • giải phóng bộ nhớ khi không dùng nữa

Trong đó, bước đầu tiên và cuối cùng sẽ có nhiều khác biệt giữa các ngôn ngữ. Với những ngôn ngữ như C hay C++, mọi việc phải làm thủ công, tức là lập trình viên phải viết code để thực hiện toàn bộ việc cấp phát/thu hồi bộ nhớ. Còn với JavaScript, lập trình viên thường không phải vất vả như vậy, engine trên trình duyệt có cơ chế tự động.

Bộ nhớ được cấp phát khi không được sử dụng nữa sẽ trở thành rác (garbage). Vì vậy cơ chế này được gọi là cơ chế thu hồi rác (garbage collection). Nếu không được thu hồi đúng cách, các chương trình khác không thể dùng phần bộ nhớ đó được nữa. Hiện tượng này sẽ dẫn đến vấn đề gọi là memory leak.

Các ngôn ngữ lập trình khác nhau cũng có nhiều phương thức (hay thuật toán) khác nhau để thu hồi bộ nhớ. Với JavaScript, mọi thứ đều được tự động hóa. Tuy nhiên, điều đó không có nghĩa là JavaScript không có nguy cơ bị memory leak. Nếu code không được kiểm soát tốt dẫn đến lặp vô hạn, hoặc callback hell cũng có thể dẫn đến memory leak.

Việc cấp phát bộ nhớ của JavaScript tương đối dễ hiểu. Khi một biến mới được khởi tạo, bộ nhớ sẽ được cấp phát.

let foo = "bar";

Sau đó, tùy theo scope của biến, nếu bộ nhớ không còn được sử dụng nữa, bộ nhớ sẽ được giải phóng.

Cơ chế thu hồi rác

JavaScript có hai chiến thuật để thu hồi rác: reference-counting và mark-and-sweep.

Kỹ thuật reference-counting có tính linh hoạt cao, được nhiều ngôn ngữ áp dụng. Mỗi object được lưu trong bộ nhớ sẽ lưu luôn thông tin lưu số lượng tham chiếu đến nó. Khi số tham chiếu này bằng 0, nghĩa là bộ nhớ không còn được dùng nữa, nó sẽ được thu hồi.

Ví dụ một đoạn code như dưới đây:

let foo = {
    bar: "bar",
};

foo = "";

Sẽ có hai object được khởi tạo: foobar. Sau khi foo được gán giá trị mới, bar không còn được dùng nữa và phần bộ nhớ này sẽ được thu hồi.

Có một số trường hợp, mọi chuyện sẽ phức tạp hơn:

const check = () => {
    let foo = {};
    let bar = {};
    foo.bar = bar;
    bar.foo = foo;
    return true;
}
check();

Trong ví dụ trên, việc cấp phát/thu hồi bộ nhớ tương đối phức tạp. Các biến foobar bên trong hàm check sẽ tham chiếu lẫn nhau. Thông thường, sau khi hàm đã được thực thi xong, các biến cục bộ trong hàm sẽ không còn được dùng nữa và bộ nhớ sẽ được thu hồi. Thế nhưng, trong trường hợp này, bộ nhớ dành cho hai biến này sẽ không được thu hồi vì số lượng tham chiếu không bằng 0.

Lúc này, chiến thuật tiếp theo của cơ chế thu hồi rác sẽ được thực thi: mark-and-sweep. Thuật toán này xây dựng một cây các object, kể từ object cao nhất là root. Nếu một object được duyệt bởi thuật toán này, nó sẽ được đánh dấu (mark) là vẫn còn sử dụng. Những object không được đánh dấu sẽ được quét (sweep) sau khi toàn bộ object đã được duyệt xong và bộ nhớ tương ứng sẽ được thu hồi.

Cơ chế thu hồi rác của Node.js

Node.js hoạt động dựa trên V8 engine nhưng cơ chế thu hồi rác của Node.js khác nhiều so với JavaScript trên trình duyệt.

Node.js tổ chức bộ nhớ thành hai vùng: heap và stack. Heap là vùng nhớ động, có thể mở rộng, còn stack là vùng nhớ tĩnh. Những dữ liệu nguyên thủy như số, string, boolean, null, undefined sẽ được lưu vào stack.

Những dữ liệu khác sẽ được coi là object và được lưu ở heap. Trong JavaScript, hầu như mọi thứ đều là object, kể cả hàm. Dữ liệu trong vùng nhớ heap được truy cập theo tham chiếu. Có nghĩa là, nếu gán một object đang tồn tại cho một biến, object đó sẽ không được tạo thêm, không clone. Chỉ đơn giản là tham chiếu của nó sẽ được gán cho biến đó mà thôi.

Vùng nhớ heap lại được chia thành hai phần: new space và old space. New space là phần bộ nhớ lưu các object và biến mới. Đây là vùng nhớ được thiết kế để có thể nhanh chóng thu hồi rác.

Dữ liệu trong phần new space, sau một thời gian nhất định mà không bị thu hồi sẽ được chuyển sang old space. Node.js sẽ hạn chế việc chuyển dữ liệu từ new sang old space. Chỉ khoảng 20% số object từ new space sẽ được chuyển sang old space.

Với mỗi phần bộ nhớ, cơ chế thu hồi rác lại có sự khác biệt:

  • Với new space, thuật toán được dùng là scavenge. Việc thu hồi rác trong phần bộ nhớ này tương đối đơn giản và dễ hiểu: object nào không được tham chiếu nữa thì bộ nhớ sẽ được thu hồi.
  • Với old space, thuật toán mark-and-sweep tương tự như JavaScript trên trình duyệt sẽ được sử dụng.

Memory leak

Memory leak sẽ xảy ra khi mà bộ nhớ không được dùng nữa nhưng không được thu hồi. Dù cơ chế thu hồi rác của JavaScript đã được tự động hóa, không có nghĩa nguy cơ memory leak đã mất.

Ví dụ một server Node.js như sau. Server này sẽ lưu toàn bộ thông tin truy vấn như một dạng log. Đây có thể coi là một ví dụ điển hình của việc tổ chức code kém.

Đây là một ví dụ hơi thô thiển, nhưng trên thực tế tôi đã gặp không ít trường hợp tương tự như vậy. Sử dụng biến toàn cục rất tiện, nhưng không kiểm soát tốt sẽ nảy sinh nhiều vấn đề.

const http = require("http");

const log = [];
const server = http.createServer((req, res) => {
    let chunk = JSON.stringify({ url: req.url, now: new Date() });
    log.push(chunk);

    res.writeHead(200);
    res.end(JSON.stringify(ml_Var));
});

const PORT = process.env.PORT || 3000;
server.listen(PORT);

Nếu tổ chức code như trên, biến log là biến toàn cục, do đó, nó sẽ luôn được sử dụng cho đến khi chương trình kết thúc. Điều đó có nghĩa là, bộ nhớ dành cho biến này sẽ không được thu hồi cho đến khi chúng ta tắt server.

Những object như vậy sẽ gây ra memory leak. Nhất là khi nó có thể được thêm/bớt ở những đoạn code khác. Bản thân nó cũng sẽ liên tục phình to khi có truy vấn đến server.

Với những server Node.js trong thực tế, hàng triệu truy vấn đồng thời có thể được gửi đến, hàng triệu phần tử sẽ được thêm vào object chỉ trong vài giây. Rõ ràng, bộ nhớ dành cho nó cũng sẽ liên tục phình ra mà không được thu hồi, trừ khi server Node.js bị tắt. Mà thực tế, ít khi người ta tắt hay khởi động lại server lắm.

Nhận ra vấn đề rồi thì giải quyết nó cũng đơn giản thôi. Ghi log vào file và lưu trữ trên bộ nhớ ngoài hoặc database sẽ giải quyết được vấn đề.

Kết luận

Dù cơ chế đã được tự động hóa, việc hiểu cơ chế cấp phát/thu hồi bộ nhớ cũng đem lại nhiều lợi ích trong quá trình lập trình. Hiểu được quá trình này, việc viết các ứng dụng có hiệu suất tốt hơn cũng phần nào dễ dàng hơn.

Tôi xin lỗi nếu bài viết có bất kỳ typo nào. Nếu bạn nhận thấy điều gì bất thường, xin hãy cho tôi biết.

Nếu có bất điều gì muốn nói, bạn có thể liên hệ với tôi qua các mạng xã hội, tạo discussion hoặc report issue trên Github.