Event Loop trong Node.js: Cơ chế hoạt động và ví dụ thực tế
1. Tổng quan
Event Loop là cơ chế lõi giúp Node.js xử lý hàng ngàn request đồng thời mà chỉ dùng một thread duy nhất cho JavaScript. Nhiều bạn viết code async/await, setTimeout, Promise hàng ngày nhưng không rõ thứ tự thực thi thực sự diễn ra như thế nào.
Bài này giải thích cấu trúc Event Loop qua các pha (phase) cụ thể, phân biệt microtask và macrotask, rồi minh họa bằng code chạy được. Đọc xong, bạn sẽ dự đoán chính xác thứ tự log của các đoạn code bất đồng bộ phức tạp và tránh được các lỗi phổ biến như block event loop.
2. Node.js hoạt động single-thread như thế nào
Node.js chạy JavaScript trên một thread chính (main thread), nhưng các tác vụ I/O (đọc file, gọi network, truy vấn DB) được đẩy xuống libuv — thư viện C xử lý I/O bất đồng bộ bằng thread pool hoặc cơ chế kernel (epoll, kqueue…).
┌───────────────────────────┐
│ JavaScript (single thread) │
└──────────────┬────────────┘
│ gọi fs.readFile, setTimeout, net...
▼
┌───────────────────────────┐
│ libuv (C/C++) │
│ - Thread pool │
│ - Event loop │
│ - OS async APIs │
└───────────────────────────┘
Khi tác vụ I/O hoàn tất, libuv đưa callback vào hàng đợi tương ứng. Event Loop liên tục kiểm tra các hàng đợi này và thực thi callback khi đến lượt — đó chính là “loop”.
Lưu ý: Code JavaScript của bạn luôn chạy trên một thread. Nếu bạn viết code đồng bộ nặng (ví dụ loop tính toán lớn), nó sẽ block toàn bộ event loop, khiến mọi request khác phải chờ.
3. Các pha của Event Loop
Event Loop không chạy một vòng lặp đơn giản mà đi qua 6 pha theo thứ tự cố định mỗi lần lặp (tick):
┌───────────────────────┐
┌─>│ timers │ setTimeout, setInterval
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ pending callbacks │ I/O callback bị delay
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ idle, prepare │
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ poll │ I/O events (đọc file, network)
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ check │ setImmediate
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
└──┤ close callbacks │ socket.on('close')
└─────────────────────────┘
3.1. Mô tả ngắn từng pha
- timers: thực thi callback của
setTimeout/setIntervalđã hết hạn. - pending callbacks: thực thi I/O callback bị trì hoãn từ chu kỳ trước.
- poll: chờ và xử lý I/O event mới (đọc file xong, nhận response network…). Đây là pha tốn thời gian nhất.
- check: thực thi callback của
setImmediate. - close callbacks: xử lý event đóng, ví dụ
socket.on('close', ...).
Mỗi pha có một hàng đợi (queue) riêng. Event Loop xử lý hết (hoặc đến giới hạn) các callback trong hàng đợi của pha hiện tại rồi mới chuyển sang pha kế tiếp.
4. Microtask queue: Promise và process.nextTick
Ngoài 6 pha macrotask trên, Node.js còn có microtask queue, được xử lý giữa mỗi pha — không phải chờ hết một vòng loop mới chạy.
Có hai loại microtask:
process.nextTick()— hàng đợi riêng, luôn được xử lý trước mọi microtask khác.Promise.then/catch/finally— hàng đợi microtask chuẩn (theo spec ECMAScript).
console.log('1 - sync start');
setTimeout(() => console.log('2 - setTimeout'), 0);
Promise.resolve().then(() => console.log('3 - promise'));
process.nextTick(() => console.log('4 - nextTick'));
console.log('5 - sync end');
Giải thích:
- Dòng
console.logđồng bộ chạy ngay (1,5). setTimeoutđẩy callback vào hàng đợi timers — chạy ở pha tiếp theo.process.nextTickvàPromise.thenđều là microtask, nhưngnextTickcó độ ưu tiên cao hơn.- Sau khi code đồng bộ chạy xong, Node.js xả hết
nextTickqueue, rồi xả hếtPromisemicrotask queue, trước khi vào pha timers.
1 - sync start
5 - sync end
4 - nextTick
3 - promise
2 - setTimeout
Lưu ý: Nếu bạn gọi
process.nextTick()liên tục đệ quy (ví dụ trong một callback khác lại gọi tiếpnextTick), event loop sẽ không bao giờ sang pha tiếp theo — gọi là “I/O starvation”.
5. setTimeout vs setImmediate: thứ tự nào đúng?
Đây là câu hỏi gây nhầm lẫn nhất. Thứ tự giữa setTimeout(fn, 0) và setImmediate(fn) không cố định nếu gọi ở top-level, nhưng cố định nếu gọi trong I/O callback.
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
Vì đoạn code này nằm trong callback của fs.readFile — tức đang ở pha poll — nên khi poll kết thúc, Event Loop luôn chuyển sang pha check (setImmediate) trước khi vòng lặp mới quay lại pha timers.
immediate
timeout
Ngược lại, nếu đặt hai lệnh này ở top-level (ngoài bất kỳ callback I/O nào), thứ tự phụ thuộc vào hiệu năng của tiến trình khởi động Node.js lúc đó — không nên dựa vào thứ tự này trong code production.
6. Block event loop: lỗi phổ biến nhất
Vì chỉ có một thread xử lý JavaScript, bất kỳ đoạn code đồng bộ chạy lâu nào đều chặn toàn bộ server.
const express = require('express');
const app = express();
app.get('/fibonacci', (req, res) => {
function fib(n) {
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
res.send(`Result: ${fib(40)}`); // blocking!
});
app.listen(3000);
Khi một request gọi /fibonacci, hàm fib(40) chạy đồng bộ, tốn vài giây. Trong lúc đó, mọi request khác (dù chỉ là ping đơn giản) đều phải chờ, vì event loop bị kẹt ở một task.
6.1. Cách khắc giải: đẩy việc nặng ra Worker Thread
const { Worker } = require('worker_threads');
app.get('/fibonacci', (req, res) => {
const worker = new Worker('./fib-worker.js', {
workerData: { n: 40 }
});
worker.on('message', (result) => res.send(`Result: ${result}`));
worker.on('error', (err) => res.status(500).send(err.message));
});
// fib-worker.js
const { workerData, parentPort } = require('worker_threads');
function fib(n) {
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
parentPort.postMessage(fib(workerData.n));
Worker chạy trên thread riêng, không chiếm main thread. Event loop của request chính vẫn rảnh để xử lý các request khác trong lúc worker tính toán.
Khi nào KHÔNG cần Worker Thread: với các tác vụ I/O (đọc DB, gọi API, đọc file) — đây đã là bất đồng bộ sẵn, không cần worker. Worker Thread chỉ hữu ích cho CPU-bound task (tính toán nặng, mã hoá, xử lý ảnh…).
7. Đo độ trễ event loop bằng perf_hooks
Để phát hiện event loop bị block trong môi trường production, bạn có thể dùng module perf_hooks built-in.
const { monitorEventLoopDelay } = require('perf_hooks');
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
setInterval(() => {
console.log(`Event loop delay (mean): ${histogram.mean / 1e6} ms`);
histogram.reset();
}, 5000);
monitorEventLoopDelay tạo một histogram đo thời gian event loop bị “trễ” so với lịch trình lý tưởng. Nếu giá trị mean tăng cao bất thường (ví dụ trên 100ms), đó là dấu hiệu có code block đâu đó.
8. Viết test kiểm tra thứ tự thực thi
Để xác nhận hiểu đúng về thứ tự microtask/macrotask, bạn có thể viết test ghi lại log vào một array rồi assert thứ tự.
// order.js
function recordOrder(log) {
log.push('sync-start');
setTimeout(() => log.push('timeout'), 0);
Promise.resolve().then(() => log.push('promise'));
process.nextTick(() => log.push('next-tick'));
log.push('sync-end');
}
module.exports = { recordOrder };
// order.test.js
const { recordOrder } = require('./order');
test('thứ tự thực thi: sync -> nextTick -> promise -> timeout', (done) => {
const log = [];
recordOrder(log);
// sync code chạy xong ngay
expect(log).toEqual(['sync-start', 'sync-end']);
setImmediate(() => {
expect(log).toEqual([
'sync-start',
'sync-end',
'next-tick',
'promise',
'timeout',
]);
done();
});
});
Test này dùng Jest với setImmediate để đợi đến khi tất cả microtask và pha timers chạy xong, rồi assert thứ tự cuối cùng của log. Đây là cách xác thực chính xác hiểu biết về event loop thay vì chỉ đọc log bằng mắt.
npx jest order.test.js
PASS ./order.test.js
✓ thứ tự thực thi: sync -> nextTick -> promise -> timeout (5 ms)
9. Các lỗi thường gặp
- Lạm dụng
process.nextTickđệ quy: gây I/O starvation, server không xử lý được request mới. - Tưởng
setTimeout(fn, 0)chạy ngay lập tức: thực tế nó luôn chờ ít nhất một tick của event loop, và chờ sau toàn bộ microtask. - Chạy code CPU-bound trên main thread: ví dụ parse JSON khổng lồ, mã hoá bcrypt với cost cao, xử lý ảnh đồng bộ — tất cả đều nên đẩy ra Worker Thread hoặc tiến trình con.
- Giả định thứ tự
setTimeoutvssetImmediateở top-level: không đáng tin cậy, tránh viết logic phụ thuộc vào nó.
Khi nào nên cẩn trọng: nếu ứng dụng của bạn có endpoint xử lý dữ liệu lớn (export Excel, tính toán thống kê, resize ảnh), hãy đo
monitorEventLoopDelaythường xuyên để phát hiện sớm trước khi ảnh hưởng toàn bộ server.
10. Kết luận
Event Loop là lý do Node.js xử lý được số lượng connection lớn với tài nguyên thấp, nhưng đi kèm là ràng buộc: mọi code JavaScript đồng bộ đều chạy trên một thread duy nhất. Hiểu rõ 6 pha (timers, pending callbacks, poll, check, close) và vị trí của microtask queue (process.nextTick, Promise) giúp bạn dự đoán đúng thứ tự thực thi và tránh các lỗi khó debug.
Khi gặp tác vụ CPU-bound, đừng ngại dùng worker_threads hoặc tách sang tiến trình riêng — event loop chỉ mạnh khi bạn không làm nó nghẹt thở.