Bỏ qua để đến nội dung
Knowledge Hub

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

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.

Đâ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.

Đ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ó.

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 1
B1c nextTick 2 (đặt từ trong nextTick)
B2 promise 1
B1b promise đặt từ trong nextTick
B3 promise 2 (đặt từ trong promise)
B4 nextTick đặt từ trong promise

Cá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ỏ.

A1 I/O callback: setImmediate
A2 I/O callback: setTimeout 0

Bê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 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.

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.

  1. Export monitorEventLoopDelay p99 và eventLoopUtilization thành metric, đặt alert theo p99.
  2. Giới hạn kích thước body và thời gian xử lý đồng bộ trong handler; không JSON.parse hoặc validate payload không giới hạn.
  3. Việc CPU lớn đi qua worker_threads hoặc service riêng, có giới hạn concurrency.
  4. Không đệ quy process.nextTick hoặc Promise vô hạn; dùng setImmediate để nhường I/O.
  5. Nếu dùng nhiều fs/crypto/zlib async đồng thời, theo dõi threadpool; chỉ tăng UV_THREADPOOL_SIZE sau khi đo thấy pool là nút thắt.
  6. Không viết test dựa trên thứ tự setTimeout(0) và setImmediate ở top-level.

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):

Terminal window
npx --prefix examples ts-node examples/src/node-event-loop/ordering-demo.ts
npx --prefix examples ts-node examples/src/node-event-loop/delay-demo.ts