Kiến trúc Node.js - Event Loop

Kiến trúc Node.js - Event Loop

Vấn đề

Trong bài viết trước chúng ta đã biết Event loop là một thành phần quan trọng trong JavaScript/Node.js. Nhiệm vụ của nó là mang các hàm callback ở trong Callback queue quay trở lại call stack. Nhưng câu chuyện chưa dừng lại ở đó, Event loop có cách mang các hàm quay trở lại khác nhau. Hay nói cách khác là các hàm callback có thứ tự ưu tiên khác nhau. Độ ưu tiên càng lớn thì Event loop càng mang nó quay trở lại call stack nhanh hơn. Hiểu được cơ chế này bạn đọc sẽ viết ra được chương trình tối ưu về mặt hiệu suất.

Trước tiên hãy tìm hiểu các pha (phases) của Event loop để biết thứ tự ưu tiên của các hàm callback.

Các pha (Phases) của Event loop

Hãy tưởng tượng Event loop như một bánh xe chia thành nhiều ngăn, cụ thể ở đây là 6. Để quay hết một vòng cần phải đi qua đủ 6 ngăn. Ở mỗi ngăn, Event loop kiểm tra xem có hàm callback nào đáp ứng tiêu chí thì sẽ "bốc" nó quay trở lại call stack.

Dưới đây là sơ đồ các pha của vòng lặp sự kiện.

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll            │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

Bây giờ hãy đi vào chi tiết từng pha.

timers

Timers là nơi xử lý các callback của setTimeoutsetInterval. Khi thời gian chờ được thiết lập đã trôi qua, callback tương ứng sẽ được thực thi tại đây.

Cũng cần phải lưu ý rằng đây là thời gian chờ tối thiểu, không phải chính xác tuyệt đối do phụ thuộc vào độ trễ của các pha khác.

Ví dụ.

const fs = require('node:fs');

function someAsyncOperation(callback) {
  // Assume this takes 95ms to complete
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();
setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;
  console.log(`${delay}ms have passed since I was scheduled`);
}, 100);

// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
  const startCallback = Date.now();
  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

Giả sử someAsyncOperation mất 95ms để hoàn thành, nó nhỏ hơn thời gian chờ 100ms của hàm setTimeout. Về lý thuyết, lệnh console.log trong setTimeout in ra delay bằng 100ms đúng với thời gian chờ nhưng hãy để ý hàm callback của someAsyncOperation đang "kẹt" ở pha pool (lệnh while đã kịp đưa vào call stack) nên mất một độ trễ nhất định thì callback trong setTimeout mới được đưa vào call stack. Lúc này chúng ta thấy thời gian delay lớn hơn 100ms vài đơn vị.

pending callbacks

Pending callbacks xử lý các callback cho một số hoạt động hệ thống như các loại lỗi TCP, lỗi trong hệ thống tệp I/O (như fs).

idle, prepare

Idle, prepare là giai đoạn sử dụng nội bộ của Node.js nhiều hơn là mã của người dùng.

poll

Poll là giai đoạn trung tâm của xử lý bất đồng bộ. Poll chờ và xử lý hầu hết các sự kiện I/O như đọc/ghi tệp tin, kết nối mạng,... Poll có hai chức năng chính: Tính toán thời gian cần chặn và Xử lý các sự kiện trong hàng đợi poll.

Khi vòng lặp sự kiện vào đến giai đoạn poll, nó thực hiện 2 công việc:

  • Nếu có callback I/O: thực thi ngay.
  • Nếu không có callback và:
    • Có callback setImmediate: kết thúc poll, chuyển sang pha check.
    • Không có setImmediate và chưa hết thời gian chờ: chờ tiếp.

Poll có một ngưỡng thời gian chờ nhất định khi nó đang trống trước khi chuyển sang giai đoạn tiếp theo.

check

Check xử lý các callback được đăng ký qua setImmediate.

close callbacks

Close callbacks xử lý các callback khi một tài nguyên kết nối đóng đột ngột. Ví dụ socket.destroy() hoặc sự kiện close được phát ra.

Ngoài 6 pha trên, Node.js còn có một "pha đặc biệt" nữa là process.nextTick().

process.nextTick()

process.nextTick() không được hiển thị trong sơ đồ 6 pha của vòng lặp sự kiện mặc dù nó là một phần của API không đồng bộ. Điều này là do về mặt kỹ thuật nó không phải là một phần của vòng lặp sự kiện. Thay vào đó, hàm callback của process.nextTick() sẽ được xử lý sau khi hoạt động của pha hiện tại hoàn tất. Hay nói cách khác, nó luôn được ưu tiên xử lý đầu tiên mỗi khi vòng lặp sự kiện bước vào pha tiếp theo.

Cũng chính vì process.nextTick() luôn được thực thi trước khi bước vào pha nên nó có khả năng chặn vòng lặp sự kiện như ví dụ dưới đây.

function endlessLoop() {
  process.nextTick(endlessLoop);
}
endlessLoop();

Trong bài viết tiếp theo, chúng ta hãy đi sâu vào tìm hiểu process.nextTick() nhé.

Kết luận

Event Loop là một thành phần cốt lõi trong kiến trúc của Node.js, đóng vai trò điều phối các tác vụ bất đồng bộ bằng cách đưa các hàm callback từ hàng đợi (Callback queue) vào Call Stack để thực thi. Trong bài viết này, chúng ta đã khám phá 6 pha chính của Event Loop, bao gồm timers, pending callbacks, idle, prepare, poll, check, và close callbacks, mỗi pha đảm nhận nhiệm vụ xử lý các loại callback khác nhau. Timers chịu trách nhiệm cho các hàm setTimeoutsetInterval, trong khi Poll là trung tâm xử lý các sự kiện I/O và quyết định chuyển tiếp giữa các pha. Ngoài ra, setImmediate trong pha Check và các callback đột ngột trong Close đều có vai trò riêng biệt trong cơ chế vận hành. Đặc biệt, process.nextTick() tuy không thuộc Event loop nhưng lại có mức độ ưu tiên cao nhất, đảm bảo callback của nó luôn được thực thi ngay lập tức sau pha hiện tại.

Hiểu rõ cơ chế hoạt động của Event Loop và thứ tự ưu tiên của từng pha là chìa khóa để tối ưu hóa hiệu suất chương trình Node.js. Điều này giúp bạn kiểm soát tốt hơn các tác vụ bất đồng bộ, từ đó giảm thiểu độ trễ và tối ưu hóa trải nghiệm tổng thể. Trong bài viết tiếp theo, chúng ta sẽ đi sâu hơn vào process.nextTick() để khai thác triệt để tiềm năng mà Node.js mang lại.

Tham khảo: