Event loop Node.js: thứ tự callback, timer trễ và những gì chặn main thread Trung cấp
Thời gian đọc ước tính: 8 phút
Vấn đề
Phần tiêu đề “Vấn đề”Một API Node.js gọi database bất đồng bộ nên đội tin rằng server phục vụ được nhiều request cùng lúc. Rồi một handler mới JSON.parse payload lớn mất 300 ms. Mọi request khác tăng latency, kể cả route chỉ trả pong; một setTimeout(fn, 0) cũng chạy muộn hàng trăm ms.
Nguyên nhân: JavaScript callback chạy trên một main thread. Event loop chỉ chọn được callback tiếp theo khi callback hiện tại trả quyền điều khiển. Bài này cho bạn mô hình đủ chính xác để đọc log, đoán đúng thứ tự thực thi và tìm nút nghẽn.
Quy tắc cần nhớ
Phần tiêu đề “Quy tắc cần nhớ”Đây là các quy tắc xác định (deterministic). Mọi thứ khác bài sẽ nói rõ là không bảo đảm.
| # | Quy tắc | Xác minh |
|---|---|---|
| 1 | Sau mỗi callback, Node drain toàn bộ nextTick queue, rồi toàn bộ microtask (Promise) queue, lặp tới khi cả hai rỗng |
Demo bên dưới, Node 24.17 |
| 2 | Trong callback I/O, setImmediate luôn chạy trước setTimeout(0) |
Tài liệu Node + demo |
| 3 | setTimeout(fn, 0) thực chất là setTimeout(fn, 1) |
Tài liệu timers |
| 4 | Ở top-level, thứ tự setTimeout(0) và setImmediate không xác định |
Tài liệu Node |
| 5 | Libuv threadpool mặc định 4 thread, chỉnh bằng UV_THREADPOOL_SIZE (tối đa 1024) |
Tài liệu libuv |
| 6 | async không làm code CPU chạy nền; tính toán đồng bộ luôn chiếm main thread |
Cơ chế |
Ngoại lệ duy nhất của quy tắc 1: ở ES module (.mjs), code top-level của module chính chạy bên trong một microtask, nên Promise .then chạy trước nextTick. CommonJS thì ngược lại. Đã đo trên Node 24.17: file .mjs in promise rồi nextTick; file .js in nextTick rồi promise.
Sáu phase của libuv
Phần tiêu đề “Sáu phase của libuv”Đang tải diagram…
Sáu phase không phải sáu thread hay sáu queue chạy song song. Event loop đi lần lượt qua từng phase và chạy callback đã sẵn sàng trong phase đó. nextTick queue và microtask queue không phải phase: chúng được drain giữa các callback, bất kể đang ở phase nào (nút tròn trong sơ đồ).
| Phase | Chạy gì | Ghi chú |
|---|---|---|
| timers | Callback setTimeout/setInterval đã đến hạn |
Hạn là ngưỡng tối thiểu, không phải giờ hẹn chính xác |
| pending | Một số callback I/O hệ thống bị hoãn | Hiếm khi cần quan tâm |
| idle, prepare | Nội bộ libuv | Không liên quan code ứng dụng |
| poll | Hầu hết callback I/O; chờ sự kiện mới nếu không có việc | Nếu có setImmediate thì không chờ lâu |
| check | Callback setImmediate |
Chạy ngay sau poll |
| close | Một số sự kiện đóng, ví dụ socket.on('close') |
Về thứ tự: từ libuv 1.45.0 (Node 20), timers chạy sau poll trong mỗi vòng thay vì trước poll; để tương thích, libuv vẫn chạy timers một lần trước khi vào vòng lặp. Thay đổi này chỉ ảnh hưởng thứ tự setImmediate và timer ở một số tình huống biên, nên đừng viết code phụ thuộc vào nó.
nextTick và microtask
Phần tiêu đề “nextTick và microtask”Demo ordering-demo.ts đăng ký lồng nhau trong một callback timer. Output thật trên Node 24.17, CommonJS:
B1 nextTick 1B1c nextTick 2 (đặt từ trong nextTick)B2 promise 1B1b promise đặt từ trong nextTickB3 promise 2 (đặt từ trong promise)B4 nextTick đặt từ trong promiseCách đọc: nextTick queue được drain hết (kể cả B1c vừa thêm), rồi tới microtask queue theo FIFO (B2, B1b, B3). B4 là nextTick thêm từ trong microtask nên phải chờ microtask queue rỗng mới chạy.
Hệ quả vận hành: hai queue này không nhường cho I/O. Một chuỗi nextTick hoặc Promise tự gọi lại vô hạn sẽ làm I/O và timer chết đói dù mỗi callback rất nhỏ.
Timer và setImmediate
Phần tiêu đề “Timer và setImmediate”A1 I/O callback: setImmediateA2 I/O callback: setTimeout 0Bên trong callback readFile, setImmediate (phase check) luôn đến trước timer của vòng sau. Đây là lý do setImmediate là lựa chọn đúng khi cần “chạy sau khi I/O hiện tại xong”.
setTimeout(fn, 0) bị đổi thành 1 ms (tài liệu timers). Đo 5 timer 0 ms nối tiếp, ba lần chạy cho 6.0, 11.0 và 4.9 ms: tối thiểu khoảng 1 ms mỗi lượt, cộng thêm jitter của scheduler. Không bao giờ coi 0 là “chạy ngay”.
Việc nặng thì chạy ở đâu
Phần tiêu đề “Việc nặng thì chạy ở đâu”| Việc | Chạy ở | Hệ quả |
|---|---|---|
JSON.parse, regex nặng, vòng lặp CPU |
Main thread | Chặn mọi callback khác |
fs.*, dns.lookup, crypto.pbkdf2, zlib bất đồng bộ |
Libuv threadpool (4 thread) | Việc thứ 5 trở đi phải xếp hàng |
| Socket mạng (TCP, HTTP) | Kernel (non-blocking I/O) | Không chiếm thread của pool |
| CPU-bound cần song song | worker_threads hoặc service riêng |
Tốn chi phí truyền dữ liệu |
Đã đo bằng script tạm (không nằm trong repo) trên máy 10 core, Node 24.17: 8 lần crypto.pbkdf2 async cùng lúc cho 4 việc đầu hoàn tất quanh 54 đến 65 ms, 4 việc sau quanh 107 đến 119 ms, tức đúng hai “đợt” theo pool 4 thread. Nếu timer trễ mà CPU main thread thấp, hãy kiểm tra threadpool có đang bị cạn không.
Đo event loop delay
Phần tiêu đề “Đo event loop delay”Demo delay-demo.ts: nghỉ 300 ms, chặn main thread 200 ms, rồi in histogram của monitorEventLoopDelay({ resolution: 10 }). Hai lần chạy thật:
| Chỉ số | Lần 1 | Lần 2 |
|---|---|---|
| min | 9.2 ms | 10.0 ms |
| p50 | 11.0 ms | 11.0 ms |
| p99 | 13.4 ms | 12.3 ms |
| max | 204.9 ms | 206.0 ms |
eventLoopUtilization |
0.27 | 0.27 |
Đọc kết quả: histogram ghi độ trễ của timer lấy mẫu mỗi 10 ms, nên giá trị nền là khoảng 10 đến 11 ms (đó là chu kỳ lấy mẫu, không phải lỗi). Một lần chặn 200 ms làm max vọt lên khoảng 205 ms trong khi p50 gần như không đổi. Vì vậy alert theo p99 hoặc max, đừng theo trung bình.
import { monitorEventLoopDelay } from 'node:perf_hooks';
const h = monitorEventLoopDelay({ resolution: 20 });h.enable();setInterval(() => { const p99Ms = h.percentile(99) / 1e6; // histogram tính bằng nanosecond h.reset(); // export p99Ms ra metric của bạn}, 10_000).unref();Delay cao chỉ cho biết main thread bị chiếm, chưa chỉ ra hàm nào. Bước tiếp theo là CPU profile và log kích thước payload.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Checklist production
Phần tiêu đề “Checklist production”- Export
monitorEventLoopDelayp99 vàeventLoopUtilizationthành metric, đặt alert theo p99. - Giới hạn kích thước body và thời gian xử lý đồng bộ trong handler; không
JSON.parsehoặc validate payload không giới hạn. - Việc CPU lớn đi qua
worker_threadshoặc service riêng, có giới hạn concurrency. - Không đệ quy
process.nextTickhoặc Promise vô hạn; dùngsetImmediateđể nhường I/O. - Nếu dùng nhiều
fs/crypto/zlibasync đồng thời, theo dõi threadpool; chỉ tăngUV_THREADPOOL_SIZEsau khi đo thấy pool là nút thắt. - Không viết test dựa trên thứ tự
setTimeout(0)vàsetImmediateở top-level.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Tóm tắt
Phần tiêu đề “Tóm tắt”Tự kiểm tra: (1) Vì sao nextTick đệ quy vô hạn làm timer không bao giờ chạy? (2) Trong callback fs.readFile, setTimeout(0) và setImmediate chạy theo thứ tự nào? (3) Timer trễ nhưng CPU main thread thấp: nên kiểm tra gì?
Chạy demo (cần Node 24, không cần dịch vụ ngoài):
npx --prefix examples ts-node examples/src/node-event-loop/ordering-demo.tsnpx --prefix examples ts-node examples/src/node-event-loop/delay-demo.ts