Giới hạn bộ nhớ trong Node.js

Giới hạn bộ nhớ trong Node.js
Photo by Sergey Pesterev from Unsplash

Quản lý, cấp phát bộ nhớ luôn là vấn đề quan trọng của mọi chương trình máy tính. Trong bài viết này, tôi sẽ trình bày cách thức Node.js cấp phát và quản lý bộ nhớ. Đồng thời, tôi sẽ trình bày một số phương pháp để theo dõi việc sử dụng bộ nhớ cũng như nâng cao hiệu suất của một ứng dụng Node.js.

Nhắc lại cơ chế thu hồi rác của V8 engine

Trong phần này, tôi sẽ nhắc lại một vài điểm quan trọng về cơ chế thu hồi rác của V8 engine. Nội chung chi tiết hơn tôi đã viết ở đây.

Bộ nhớ heap là được chia thành nhiều vùng “thế hệ”. Các vùng nhớ này sẽ “già”dần đi trong quá trình thực thi chương trình. Các thế hệ trong vùng nhớ heap gọi là thế hệ trẻ (young) và già (old). Thế hệ trẻ sẽ được chia thành nhiều mức độ nhỏ hơn. Và sau quá trình thu hồi rác, thế hệ trẻ sẽ thành thế hệ già.

Vùng thế hệ
Nguồn: https://v8.dev/_img/trash-talk/02.svg

Giả thuyết trong việc quản lý bộ nhớ là hầu hết các đối tượng sẽ không được sử dụng lâu dài. Cơ chế thu hồi rác sẽ thu hồi vùng nhớ cho các đối tượng ngay khi chúng còn “trẻ”. Chỉ còn rất ít đối tượng sẽ được chuyển sang thế hệ “già” sau khi thu hồi rác, sau đó chính chúng cũng sẽ bị thu hồi.

Có ba khu vực chính trong bộ nhớ của Node:

  • Code - lưu mã được thực thi
  • Call stack - lưu các hàm và biến cục bộ với các kiểu nguyên thủy như số, chuỗi, hoặc boolean
  • Bộ nhớ heap

Bộ nhớ heap là nội dung quan trọng trong bài viết này. Để hiểu hơn về cơ chế cấp phát bộ nhớ, hãy thử một hàm sau:

function allocateMemory(size) {
    const numbers = size / 8;
    const arr = [];
    arr.length = numbers;
    for (let i = 0; i < numbers; i++) {
        arr[i] = i;
    }
    return arr;
}

Trong hàm trên, các biến cục bộ sẽ được thu hồi rác ngay khi cuộc gọi hàm hết thúc trong call stack. Những biến như numbers sẽ không bao giờ đến được bộ nhớ heap. Nhưng biến arr sẽ được lưu vào heap và sẽ không bị cơ chế thu hồi rác thu hồi bộ nhớ này.

Giới hạn bộ nhớ heap

Hãy thử đoạn code sau để xem bộ nhớ heap có giới hạn bao nhiêu:

const memoryLeakAllocations = [];

const field = "heapUsed";
const allocationStep = 10000 * 1024; // 10MB

const TIME_INTERVAL = 40;

setInterval(() => {
    const allocation = allocateMemory(allocationStep);

    memoryLeakAllocations.push(allocation);

    const mu = process.memoryUsage();
    // # bytes / KB / MB / GB
    const gbNow = mu[field] / 1024 / 1024 / 1024;
    const gbRounded = Math.round(gbNow * 100) / 100;

    console.log(`Heap allocated ${gbRounded} GB`);
}, TIME_INTERVAL);

Đoạn code trên sẽ yêu cầu cấp phát bộ nhớ khoảng 10 MB trong mỗi 40ms (thời gian để cơ chế thu hồi rác chuyển các đối tượng thành “già”). process.memoryUsage là một công cụ thô sơ thu thập các số liệu xung quanh việc sử dụng bộ nhớ heap với trường heapUsed. Kết quả sẽ như sau:

Heap allocated 4 GB
Heap allocated 4.01 GB

<--- Last few GCs --->

[25885:0x7f9b88008000]    17476 ms: Mark-Compact (reduce) 4094.7 (4099.5) -> 4094.6 (4099.5) MB, 72.21 / 0.00 ms  (+ 42.7 ms in 1887 steps since start of marking, biggest step 3.9 ms, walltime since start of marking 150 ms) (average mu = 0.815, current mu[25885:0x7f9b88008000]    17631 ms: Mark-Compact (reduce) 4104.4 (4109.3) -> 4104.4 (4109.3) MB, 146.61 / 0.00 ms  (+ 0.1 ms in 1 steps since start of marking, biggest step 0.1 ms, walltime since start of marking 147 ms) (average mu = 0.659, current mu =

<--- JS stacktrace --->

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

Bộ thu hồi rác để cố gắng quản lý bộ nhớ một cách tối ưu trước khi lỗi “heap out of memory” xả ra. Quá trình này đạt giới hạn 4.01GB và mất 17.5 giây.

Tại sao lại là giới hạn khoảng 4GB? Có thể V8 engine ban đầu được thiết kế để chạy với bộ vi xử lý 32 bit và những giới hạn của bộ vi xử lý này vẫn được tiếp tục kế thừa đến ngày nay. Máy tính của tôi sử dụng bộ vi xử lý 64 bit (hầu hết máy tính bây giờ đều dùng bộ vi xử lý 64 bit) và phiên bản bản Node LTS 20.18.1. Về lý thuyết, bộ vi xử lý 64 bit có thể cấp phát bộ nhớ nhiều hơn 4GB rất nhiều lần (lên tới 16TB), thế nhưng giới hạn 4GB vẫn được duy trì.

Mở rộng giới hạn bộ nhớ

Bộ thu hồi rác V8 có một tham số --max-old-space-size có sẵn cho tệp thực thi Node.js:

node index.js --max-old-space-size=8000

Điều này đặt giới hạn tối đa là 8GB (cẩn thận khi làm điều này). Máy tính của tôi có đủ chỗ với 32GB RAM, nhưng tùy vào phần cứng mà hãy đặt giới hạn phù hợp. Khi bộ nhớ vật lý hết, tiến trình sẽ sử dụng ổ cứng thông qua bộ nhớ ảo. Nếu giới hạn quá cao, máy tính có thể hỏng và việc thực thi Node.js cũng không hiệu quả.

Thử kiểm tra giới hạn mới:

Heap allocated 7.8 GB
Heap allocated 7.81 GB

<--- Last few GCs --->

[16976:000001ACB8FEB330] 45701 ms: Mark-sweep (reduce) 8000.2 (8005.3) -> 8000.2 (8006.3) MB, 1468.4 / 0.0 ms (average mu = 0.211, current mu = 0.000) last resort GC in old space requested

<--- JS stacktrace --->

FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory

Kích thước bộ nhớ heap gần đạt 8GB, nhưng không đạt đến giới hạn này. Lần này mất 45.7 giây để quá trình kết thúc. Trong quá trình vận hành ứng dụng thực tế, sẽ mất một chút thời gian để bộ nhớ bị tiêu thụ hết. Vì vậy, hiểu biết về cơ chế cấp phát bộ nhớ sẽ giúp ích rất nhiều.

Nếu bộ nhớ được cấp phát không được thu hồi đúng cách, memory leak sẽ xảy ra. Quá trình này có thể kéo dài và khi lỗi tràn bộ nhớ xuất hiện, mọi chuyện sẽ rất phức tạp để xử lý hậu quả. Việc tiêu thụ bộ nhớ cũng tăng lên nếu chương trình xử lý nhiều dữ liệu lớn. Vì vậy, nếu chương trình quá lớn, nên chia nó thành nhiều chương trình nhỏ hơn. Điều này sẽ giảm áp lực bộ nhớ trên một tiến trình duy nhất và cho phép mở rộng theo chiều ngang.

Theo dõi memory leak trong Node.js

Hàm process.memoryUsage thông qua trường heapUsed có phần giúp ích cho bài toán này. Một cách theo dõi memory leak là đặt các số liệu bộ nhớ vào một công cụ khác để giám sát và xử lý thêm.

Ví dụ một công cụ đơn giản như sau:

setInterval(() => {
    const mu = process.memoryUsage();
    // # bytes / KB / MB / GB
    const gbNow = mu[field] / 1024 / 1024 / 1024;
    const gbRounded = Math.round(gbNow * 100) / 100;

    const elapsedTimeInSecs = (Date.now() - start) / 1000;
    const timeRounded = Math.round(elapsedTimeInSecs * 100) / 100;

    fs.appendFile(LOG_FILE, timeRounded + "," + gbRounded + os.EOL, () => {}); // fire-and-forget
}, TIME_INTERVAL_IN_MSEC);

Với chương trình đơn giản này, việc cấp phát và sử dụng bộ nhớ sẽ được giám sát liên tục. Dữ liệu được ghi ra file csv và nếu cần, những công cụ phân tích dữ liệu csv có thể giúp trực quan hóa nó.

Nếu phát hiện việc sử dụng bộ nhớ tăng lên nhưng không đạt giới hạn 4.01GB, có thể memory leak đã xảy ra ở đâu đó. Việc debug trong trường hợp này sẽ tốn nhiều thời gian và công sức nhưng là việc cần thiết để tối ưu chương trình.

Trên đây chỉ là một công cụ đơn giản để theo dõi việc sử dụng bộ nhớ của Node.js. Để triển khai một ứng dụng trên production, có nhiều công cụ được phát triển để làm việc này một cách hiệu quả, ví dụ như PM2.

PM2 có cơ chế khởi động lại chương trình nếu bộ nhớ đã đạt đến giới hạn:

pm2 start index.js --max-memory-restart 8G

Một công cụ khác là module node-memwatch có thể giúp phát hiện memory leak trong quá trình thực thi chương trình:

const memwatch = require("memwatch");

memwatch.on("leak", function (info) {
    // sự kiện được phát ra
    console.log(info.reason);
});

Kết luận

Trong bài viết này, tôi đã trình bày về giới hạn cấp phát bộ nhớ heap của Node.js và một số công cụ tiềm năng để theo dõi memory leak. Đây là một bài giới thiệu nhanh, hy vọng bạn thích nó và giúp bạn hiểu hơn về giới hạn bộ nhớ và giúp ích trong quá trình phát triển các ứng dụng Node.js.

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.