Event loop Node.js — 6 phase, đi từng vòng bằng code libuv thật
Câu hỏi phỏng vấn kinh điển: "Kể các phase của event loop và cho biết đoạn code này in ra gì".
Trả lời thuộc lòng 6 cái tên thì chỉ qua được vòng junior. Interviewer Senior sẽ hỏi tiếp:
poll phase ngủ bao lâu? Vì sao setTimeout(0) với setImmediate lúc thì
thứ tự này lúc thì thứ tự kia? Server của anh bị trễ 800ms thì do phase nào? Bài này đi từ code
uv_run() thật của libuv, chứng minh từng phase bằng strace và lab, rồi tới
cách đo trong production.
Thuật ngữ dùng trong bài
event loop (vòng lặp chạy mãi trên main thread: lấy việc đã xong ra, gọi callback tương ứng) · phase (một chặng trong mỗi vòng, mỗi chặng có hàng đợi callback riêng) · tick / iteration (một vòng đi hết các phase) · libuv (thư viện C lo event loop và I/O bất đồng bộ cho Node) · handle (đối tượng sống lâu trong libuv: timer, socket, server — giữ loop "còn sống") · request (việc ngắn một lần: đọc file, ghi socket, DNS lookup) · epoll (API của Linux: một syscall chờ được hàng nghìn socket cùng lúc) · run-to-completion (một callback đã chạy thì chạy tới hết, không ai chen ngang được) · microtask (việc nhỏ chạy ngay sau callback hiện tại:Promise.then,queueMicrotask;process.nextTicklà hàng riêng còn chạy trước) — xem bài 3.
Kiểm chứng: tài liệu lấy qua Context7 (Node.js docs v22, libuv docsdesign.md,loop.md). Code dẫn trong bài lấy nguyên văn từ source libuv v1.51.0 (src/unix/core.c) và Node v22.23.1 (src/env.cc,lib/internal/timers.js) — đúng bản đang chạy lab. Mọi output là output thật trên Node 22.23.1, Linux 6.8, máy 4 core dùng chung (load average dao động 7–30 lúc chạy, nên số ms chỉ để xem tỉ lệ, đừng so tuyệt đối). Phần chưa verify được ghi rõ tại chỗ.
1. Bài toán gốc và bối cảnh ra đời
Năm 2009, cách phổ biến để viết server là thread-per-connection (mỗi kết nối một thread hoặc một process) — Apache prefork, PHP-FPM, Java servlet đời đầu. Mô hình này dễ viết vì code chạy tuần tự, nhưng mỗi kết nối "ăn" một thread dù phần lớn thời gian thread đó chỉ ngồi chờ database hay mạng. 10.000 kết nối đồng thời (bài toán C10K) = 10.000 thread, mỗi thread vài MB stack + chi phí context switch (chuyển qua lại giữa các thread) của kernel.
nginx đã chứng minh hướng khác: một thread + I/O không chặn (non-blocking) + epoll chịu được hàng chục nghìn kết nối. Nhưng viết C theo kiểu callback rất khổ. Ryan Dahl tạo Node.js (JSConf EU 2009) với ý tưởng: dùng JavaScript — ngôn ngữ vốn đã quen callback trong browser và không có sẵn API I/O chặn nào — làm lớp trên của một event loop. Ban đầu Node dùng libev + libeio; năm 2011 nhóm Node viết libuv để chạy được cả Windows (IOCP) — libuv chính là nơi event loop sống ngày nay. (Phần lịch sử: nguồn trang giới thiệu nodejs.org và lịch sử dự án libuv, không có lab.)
Từ bài toán gốc suy ra luôn Node được tối ưu cho gì:
- Tối ưu cho: nhiều kết nối đồng thời mà mỗi kết nối chủ yếu chờ I/O — API gateway, BFF, realtime (WebSocket), proxy, service gọi nhiều service khác.
- Không tối ưu cho: việc nặng CPU trong request (mã hoá ảnh, tính toán lớn, JSON khổng lồ). Chỉ có một thread chạy JS — một callback chạy 200ms là 200ms cả server đứng. Đây là cái giá phải trả để có concurrency (xử lý đồng thời) rẻ.
2. Mô hình tư duy cốt lõi
Chỉ cần nhớ hai câu, mọi hành vi khác là hệ quả:
- Event loop = một vòng lặp đơn luồng đi qua các hàng đợi theo thứ tự cố định. Mỗi phase có hàng đợi riêng; tới phase nào thì chạy callback của phase đó (tới khi hết hoặc chạm giới hạn), rồi sang phase kế tiếp. Không có ưu tiên, không chen ngang.
- Run-to-completion + xả microtask sau mỗi callback. Một callback JS đã chạy thì
chạy tới hết. Chạy xong mỗi callback, Node xả hết hàng
process.nextTickrồi hàng microtask (Promise), sau đó mới quay lại libuv.
Hệ quả trực tiếp của hai câu trên — tất cả đều được lab ở phần sau:
- Timer chỉ là ngưỡng tối thiểu, không phải hẹn giờ chính xác — vì loop phải đi hết vòng mới quay lại phase timers.
- Trong callback I/O,
setImmediateluôn chạy trướcsetTimeout(0)— vì sau poll là check, timers ở tận cuối. - Một callback chạy lâu làm mọi request khác chờ — không có preemption (cắt ngang).
- Đệ quy bằng microtask có thể "bỏ đói" loop (loop không bao giờ được sang phase kế tiếp).
3. Internals và quyết định thiết kế
3.1 Code thật: uv_run() của libuv 1.51
Mọi sơ đồ về event loop đều bắt nguồn từ hàm này (trích nguyên văn, bỏ bớt dòng trống):
// libuv v1.51.0 — src/unix/core.c
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
r = uv__loop_alive(loop);
if (!r) uv__update_time(loop);
/* Maintain backwards compatibility by processing timers before entering the
* while loop for UV_RUN_DEFAULT. Otherwise timers only need to be executed
* once, which should be done after polling in order to maintain proper
* execution order of the conceptual event loop. */
if (mode == UV_RUN_DEFAULT && r != 0 && loop->stop_flag == 0) {
uv__update_time(loop);
uv__run_timers(loop); // (1) timers — lần đầu, trước khi vào vòng
}
while (r != 0 && loop->stop_flag == 0) {
can_sleep = uv__queue_empty(&loop->pending_queue) &&
uv__queue_empty(&loop->idle_handles);
uv__run_pending(loop); // (2) pending callbacks
uv__run_idle(loop); // (3) idle
uv__run_prepare(loop); // (3) prepare
timeout = 0;
if ((mode == UV_RUN_ONCE && can_sleep) || mode == UV_RUN_DEFAULT)
timeout = uv__backend_timeout(loop); // ngủ bao lâu?
uv__io_poll(loop, timeout); // (4) poll — epoll_wait
for (r = 0; r < 8 && !uv__queue_empty(&loop->pending_queue); r++)
uv__run_pending(loop); // pending phát sinh trong poll, tối đa 8 lượt
uv__run_check(loop); // (5) check — setImmediate
uv__run_closing_handles(loop); // (6) close callbacks
uv__update_time(loop);
uv__run_timers(loop); // (1) timers — ở CUỐI vòng (libuv >= 1.45)
r = uv__loop_alive(loop);
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT) break;
}
}
Điều docs Node không vẽ ra: từ libuv 1.45 (Node 20+),uv__run_timersnằm ở cuối mỗi vòng thay vì đầu vòng, và chỉ chạy "đầu tiên" đúng một lần trước khi vàowhile(comment trong code nói rõ là để giữ tương thích). Thứ tự vòng tròn timers → pending → idle/prepare → poll → check → close không đổi — chỉ là điểm "bắt đầu vòng" dời đi. Sơ đồ trong docs Node vẫn đúng về thứ tự; nếu interviewer hỏi "timers ở đầu hay cuối vòng", câu trả lời chính xác là: khái niệm thì là đầu vòng, code libuv hiện tại thì chạy ở cuối vòng — hai cách nhìn cho cùng một chuỗi. Một thay đổi khác cũng từ bản này: pending callback phát sinh trong poll được chạy ngay sau poll (vòngfor r < 8), không phải chờ vòng sau.
3.2 Từng phase làm gì
| Phase | Hàm libuv | JS nào rơi vào đây | Ghi chú |
|---|---|---|---|
| timers | uv__run_timers | setTimeout, setInterval | Node chỉ dùng 1 uv_timer cho toàn bộ timer JS (xem 3.3). |
| pending callbacks | uv__run_pending | Gần như không thấy từ JS | Callback I/O mà libuv cố ý hoãn sang vòng sau. Docs Node lấy ví dụ ECONNREFUSED — lab 3.6 cho thấy trên Linux nó không đi đường này. |
| idle, prepare | uv__run_idle, uv__run_prepare | Không có API JS | Node dùng idle handle để cấm poll ngủ khi còn setImmediate (3.5). |
| poll | uv__io_poll → epoll_pwait | Callback socket, HTTP, kết quả fs/dns/crypto từ thread pool | Phase duy nhất loop được ngủ. Timeout tính từ timer gần nhất (3.4). |
| check | uv__run_check | setImmediate | Immediate đặt trong check chạy ở vòng sau. |
| close callbacks | uv__run_closing_handles | 'close' của socket/server khi đóng | Callback của uv_close(); libuv luôn hoãn tới phase này để handle không bị giải phóng giữa chừng. |
Lab chứng minh thứ tự, mỗi callback nằm ở một phase khác nhau:
// 03-phases.js — đặt 1 callback vào từng phase rồi xem thứ tự thật
const server = net.createServer((sock) => sock.end('hi')).listen(0, '127.0.0.1', () => {
const client = net.connect(server.address().port, '127.0.0.1');
client.on('data', () => { // poll phase (I/O mạng)
log('A. poll: socket data');
setTimeout(() => log('D. timers: setTimeout 0'), 0);
setImmediate(() => log('B. check: setImmediate'));
client.on('close', () => log('C. close: socket close'));
client.destroy();
process.nextTick(() => log('A1. nextTick (ngay sau callback đang chạy)'));
Promise.resolve().then(() => log('A2. promise microtask'));
});
});
$ node 03-phases.js
A. poll: socket data
A1. nextTick (ngay sau callback đang chạy)
A2. promise microtask
B. check: setImmediate
C. close: socket close
D. timers: setTimeout 0
Đúng thứ tự vòng: poll → (microtask) → check → close → timers.
3.3 Timers: một uv_timer cho cả nghìn timer JS
Mỗi kết nối TCP trong Node đều có timer (timeout của socket), nên số timer có thể lên hàng trăm nghìn.
lib/internal/timers.js giải bằng cấu trúc hai tầng:
- Object map theo thời lượng:
{ '40': list, '320': list }. Mọi timer cùng thời lượng (vd cùng 30.000ms) nằm trong một linked list. Timer thêm sau thì hết hạn sau → chỉ cần append vào cuối, O(1). Huỷ timer cũng O(1). - PriorityQueue (binary heap) sắp các list theo thời điểm hết hạn sớm nhất — O(log n) với n là số thời lượng khác nhau, không phải số timer.
- Chỉ một
uv_timerở libuv, luôn hẹn vào thời điểm hết hạn sớm nhất.
Đánh đổi: tối ưu cho trường hợp "rất nhiều timer, ít thời lượng khác nhau" (đúng với socket timeout).
Nếu app tạo timer với thời lượng ngẫu nhiên (vd setTimeout(fn, Math.random()*1000) cho
100k job), mỗi thời lượng thành một list riêng và lợi thế O(1) mất đi — vẫn chạy được, chỉ không còn rẻ.
(Suy ra từ comment thiết kế trong source; chưa benchmark.)
Hai giới hạn cứng trong cùng file: thời lượng < 1 hoặc > 231−1
bị ép thành 1ms. Nghĩa là setTimeout(fn, 0) thật ra là 1ms, và hẹn giờ quá
~24,8 ngày thì chạy ngay:
$ node 08-timeout-overflow.js # setTimeout(..., 30 ngày)
(node:225286) TimeoutOverflowWarning: 2592000000 does not fit into a 32-bit signed integer.
Timeout duration was set to 1.
"30 ngày" đã chạy sau 4ms
3.4 Poll phase: ngủ bao lâu?
uv__backend_timeout() quyết định loop ngủ bao lâu trong epoll_pwait:
// libuv v1.51.0 — src/unix/core.c
static int uv__backend_timeout(const uv_loop_t* loop) {
if (loop->stop_flag == 0 &&
(uv__has_active_handles(loop) || uv__has_active_reqs(loop)) &&
uv__queue_empty(&loop->pending_queue) &&
uv__queue_empty(&loop->idle_handles) && // còn setImmediate -> idle handle bật -> 0
(loop->flags & UV_LOOP_REAP_CHILDREN) == 0 &&
loop->closing_handles == NULL)
return uv__next_timeout(loop); // = thời gian tới timer gần nhất (-1 nếu không có)
return 0; // không ngủ
}
Kiểm chứng bằng strace — script chỉ có 2 timer 1000ms và 1500ms:
$ strace -e trace=epoll_pwait -T node 06-poll-sleep.js # (lọc bỏ epoll fd 3 của thread khác)
epoll_pwait(15, [], 1024, 0, NULL, 8) = 0 <0.002804>
epoll_pwait(15, [], 1024, 971, NULL, 8) = 0 <0.971928> ← ngủ 971ms: đúng thời gian còn lại tới timer 1000ms
epoll_pwait(15, [], 1024, 0, NULL, 8) = 0 <0.000318>
epoll_pwait(15, [], 1024, 1, NULL, 8) = 0 <0.001110> ← còn thiếu 1ms, ngủ thêm 1ms
timer 1000ms
epoll_pwait(15, [], 1024, 0, NULL, 8) = 0 <0.000568>
epoll_pwait(15, [], 1024, 482, NULL, 8) = 0 <0.482715> ← ngủ tiếp tới timer 1500ms
timer 1500ms
epoll_pwait trả về sớm và callback I/O chạy ngay trong poll.3.5 Check phase và mẹo idle handle
setImmediate dùng một uv_check handle (chạy sau poll). Nhưng nếu poll đang ngủ
971ms thì immediate phải chờ theo? Node chặn chuyện đó bằng một idle handle rỗng:
// Node v22.23.1 — src/env.cc
void Environment::ToggleImmediateRef(bool ref) {
if (ref) {
// Idle handle is needed only to stop the event loop from blocking in poll.
uv_idle_start(immediate_idle_handle(), [](uv_idle_t*){ });
} else {
uv_idle_stop(immediate_idle_handle());
}
}
Idle handle đang chạy → uv__backend_timeout trả 0 → poll không ngủ. strace với
script có 3 setImmediate đệ quy + 1 timer 1000ms:
$ strace -e trace=epoll_pwait -T node 06b-immediate-nosleep.js
epoll_pwait(15, [], 1024, 0, NULL, 8) = 0 <0.000075> ← còn immediate: timeout 0
epoll_pwait(15, [], 1024, 0, NULL, 8) = 0 <0.000050>
epoll_pwait(15, [], 1024, 0, NULL, 8) = 0 <0.000024>
epoll_pwait(15, [], 1024, 999, NULL, 8) = 0 <1.020711> ← hết immediate: ngủ tới timer
timer 1000ms
Immediate đặt trong lúc check đang chạy thì sang vòng sau; nextTick đặt trong nextTick thì chạy luôn:
$ node 05-immediate-nested.js
immediate 1
tick 1
tick lồng (ngay trong cùng đợt)
immediate 2
timeout 0 đặt trong check ← check → close → timers (cuối vòng này)
immediate lồng (vòng sau) ← check của vòng sau
Đánh đổi: Node chụp danh sách immediate ở đầu check phase và chỉ chạy danh sách đó, để
setImmediate đệ quy không bao giờ khoá chết loop (khác hẳn nextTick — xem bài 3).
3.6 Pending callbacks: phase "tàng hình"
Docs Node nói pending callbacks chạy "callback cho một số thao tác hệ thống như lỗi TCP ECONNREFUSED". Kiểm tra trên Linux 6.8:
$ strace -f -e trace=connect,epoll_pwait node 07-pending.js
sync: đã gọi connect()
[pid 217545] connect(21, {... sin_port=htons(1), sin_addr=inet_addr("127.0.0.1")}, 16) = -1 EINPROGRESS
[pid 217545] epoll_pwait(15, [{events=EPOLLOUT|EPOLLERR|EPOLLHUP, data={u32=21, ...}}], 1024, 0, NULL, 8) = 1
error: ECONNREFUSED
check: setImmediate
Kernel trả EINPROGRESS, lỗi về qua EPOLLERR trong poll — không phải
pending. Docs đúng cho "một số hệ *nix" nơi connect() trả lỗi ngay; trên Linux thì không.
Bài học phỏng vấn: pending callbacks là chi tiết nội bộ của libuv (callback bị hoãn), từ JS gần như
không quan sát được và không nên viết code phụ thuộc vào nó. Việc chứng minh callback nào thật sự đi
qua uv__run_pending cần debugger gắn vào libuv — chưa verify.
3.7 Khi nào process thoát: handle, request và ref
Vòng while dừng khi uv__loop_alive() = false: không còn handle đang ref,
không còn request, không còn handle đang đóng. unref() nói với libuv "đừng giữ process sống vì tôi":
$ node 15-unref.js # setInterval 100ms đã unref() + setTimeout 250ms bình thường
tick
tick
timer 250ms (ref) xong -> không còn handle ref -> thoát
exit code: 0
4. Đảm bảo và giới hạn
| Điều | Được đảm bảo? | Bằng chứng |
|---|---|---|
| Callback cùng một hàng đợi chạy FIFO (vào trước ra trước) | ✅ Có | Timer cùng thời lượng: linked list append; immediate: queue. |
Trong callback I/O: setImmediate trước setTimeout(0) | ✅ Có | Lab: 1000/1000 lần (mục 5.3). |
Ở main module: setTimeout(0) vs setImmediate | ❌ Không | Lab: 901 immediate / 99 timeout. |
| Timer chạy đúng giờ | ❌ Không — chỉ "không sớm hơn" | Lab: timer 10ms chạy lúc 55ms. |
Timer chạy ít nhất sau delay ms | ✅ Có (trừ khi delay bị ép về 1) | listOnTimeout so now - _idleStart < msecs rồi hẹn lại. |
| Một callback không bị callback khác chen ngang | ✅ Có (run-to-completion) | Chỉ 1 thread chạy JS. |
| Loop không bị bỏ đói | ❌ Không | nextTick/Promise đệ quy chặn được loop — bài 3. |
Giới hạn scale (theo lý thuyết): đây là bài toán Amdahl thu nhỏ. Thời gian mỗi request =
phần chạy JS trên main thread (tuần tự) + phần chờ I/O (song song được). Throughput tối đa của một process ≈
1 / (thời gian CPU JS mỗi request), bất kể có bao nhiêu kết nối. Ví dụ minh hoạ:
mỗi request tốn 2ms CPU JS → trần ~500 req/s/process, dù chờ DB 100ms hay 1ms. Muốn hơn thì phải nhiều
process (cluster, nhiều pod) — xem bài 4.
5. Failure modes
5.1 Callback chặn loop → mọi timer và request trễ theo
// 04-timer-threshold.js
setTimeout(() => console.log(`timeout 10ms chạy lúc ${...}ms`), 10);
fs.readFile(__filename, () => {
const s = performance.now();
while (performance.now() - s < 50); // callback I/O chặn 50ms
console.log(`readFile callback xong lúc ${...}ms`);
});
$ node 04-timer-threshold.js
readFile callback xong lúc 52.3ms
timeout 10ms chạy lúc 55.4ms
Hệ quả: không mất dữ liệu, nhưng chậm — và chậm lan ra toàn bộ server. Hệ quả phụ nguy hiểm hơn: healthcheck của load balancer / Kubernetes liveness probe cũng trễ → pod bị đánh dấu chết và restart giữa lúc tải cao → tải dồn sang pod khác → sụp dây chuyền. Cách đo và chữa: bài 4.
5.2 Viết code phụ thuộc thứ tự setTimeout(0) vs setImmediate ở main module
$ for i in $(seq 1000); do node 01-timeout-vs-immediate.js | head -1; done | sort | uniq -c
901 immediate
99 timeout
Lý do: setTimeout(0) thật ra là 1ms. Khi vào uv_run, lượt timers đầu tiên chỉ
chạy nếu đã trôi qua ≥ 1ms kể từ lúc đặt timer — phụ thuộc máy nhanh hay chậm, CPU có bị chiếm không.
Chưa tới 1ms → timer bị bỏ qua → poll → check (immediate) → timers cuối vòng.
Bận thêm 5ms trước khi vào loop thì timer gần như luôn thắng:
$ for ms in 5 20; do ...; node 01c.js $ms ...; done # bận chờ bằng performance.now()
== busy 5ms
500 timeout
== busy 20ms
500 timeout
Bẫy nhỏ gặp trong lúc làm lab: bản đầu bận chờ bằngDate.now()5ms vẫn còn 10/1000 lần immediate thắng; đổi sangperformance.now()(đồng hồ monotonic — chỉ tăng, không bị chỉnh giờ) thì 500/500. Nguyên nhân chính xác chưa verify (nghi doDate.now()làm tròn tới ms và là đồng hồ hệ thống, còn libuv dùng đồng hồ monotonic). Bài học: đo thời gian trong Node luôn dùngperformance.now()/process.hrtime.bigint().
5.3 Trong callback I/O thì thứ tự là chắc chắn
$ for i in $(seq 1000); do node 02-inside-io.js | head -1; done | sort | uniq -c
1000 immediate
Đang ở poll → phase kế là check → timers ở cuối vòng. Nếu thật sự cần "chạy sau I/O hiện tại, trước
timer", dùng setImmediate.
5.4 Process không chịu thoát (graceful shutdown treo)
Deploy rolling update: pod nhận SIGTERM, app gọi server.close()… rồi treo tới khi bị SIGKILL
sau 30s. Nguyên nhân là handle còn ref: setInterval của job định kỳ, kết nối DB pool, socket
keep-alive. Node có sẵn API để hỏi:
$ node 16-what-keeps-alive.js
server closed
còn giữ loop: [ 'Timeout' ] ← process.getActiveResourcesInfo(): chính là setInterval quên unref
Lưu ý: từ Node 19, server.close() tự đóng kết nối keep-alive đang rảnh (docs
http.Server.close) — vì vậy server trong lab đóng được. Kết nối đang xử lý request thì vẫn
phải chờ; muốn cắt hẳn thì server.closeAllConnections().
5.5 Hẹn giờ dài bằng setTimeout
Lab 3.3: hẹn 30 ngày → chạy sau 4ms. Kể cả không tràn, timer nằm trong RAM của process — deploy/restart là mất. Việc hẹn giờ nghiệp vụ phải nằm ở DB + scheduler/queue, không nằm trong event loop.
5.6 Lệch đồng hồ (clock skew)
libuv dùng đồng hồ monotonic (uv__hrtime) nên đổi giờ hệ thống / NTP nhảy giờ không
làm timer chạy sớm hay trễ. Nhưng code app so Date.now() (đồng hồ hệ thống) thì bị ảnh hưởng.
(Chưa dựng lab đổi giờ hệ thống — máy lab dùng chung, không đổi giờ được; dựa trên docs libuv
uv_now/uv_hrtime.)
6. Vận hành trong production
| Metric | Lấy từ đâu | Ngưỡng tham khảo | Ý nghĩa |
|---|---|---|---|
| Event loop delay p99/max | perf_hooks.monitorEventLoopDelay() | p99 < 50ms; max > 200ms là có callback chặn | Loop trễ bao lâu so với lẽ ra phải chạy — đo trực tiếp "đứng hình". |
| ELU (event loop utilization) | performance.eventLoopUtilization() | > 0.8 kéo dài là gần bão hoà | Tỉ lệ thời gian loop bận (không ngủ trong poll). CPU% không nói được điều này khi có thread pool. |
| Active handles / resources | process.getActiveResourcesInfo() | Tăng dần không giảm = rò | Socket/timer bị bỏ quên. |
| Heap, GC pause | process.memoryUsage(), --trace-gc | — | GC dừng main thread = cũng là chặn loop. Xem bài 6. |
Ngưỡng trong bảng là ví dụ minh hoạ, cần đặt theo SLO (mục tiêu chất lượng dịch vụ) của từng hệ thống.
Thư viện prom-client có sẵn metric nodejs_eventloop_lag_* dựa trên chính API này
(chưa verify trong lab).
Debug lúc 3h sáng — thứ tự nên làm:
- Xem event loop delay max: cao → có callback chặn. Thấp mà vẫn chậm → vấn đề ở I/O bên ngoài (DB, service khác) hoặc thread pool.
- ELU cao + CPU 100% một core → chụp CPU profile:
node --cpu-profhoặc gửiSIGUSR1để mở inspector rồi profile bằng Chrome DevTools. - ELU thấp mà fs/dns/crypto chậm → nghẽn thread pool (bài 4).
- Không thoát khi deploy →
process.getActiveResourcesInfo().
7. So sánh và ma trận quyết định
| Trục | Node.js event loop | PHP-FPM (process/request) | Go (goroutine) | Java 21+ virtual thread | Python asyncio |
|---|---|---|---|---|---|
| Mô hình | 1 thread JS + event loop | Mỗi request một process, code chặn | M:N thread, runtime tự lên lịch | M:N thread, code chặn "giả" | 1 thread + event loop |
| Code I/O viết kiểu | async/await | Tuần tự | Tuần tự | Tuần tự | async/await |
| Việc CPU nặng | Chặn cả process | Chỉ chặn 1 worker | Runtime cắt ngang được (preempt) | Tận dụng nhiều core | Chặn cả loop (+ GIL) |
| Dùng nhiều core | Phải tự: cluster / nhiều pod / worker_threads | Sẵn (nhiều process) | Sẵn | Sẵn | Phải tự |
| Chi phí mỗi kết nối chờ | Rất thấp (vài KB) | Cao (1 process) | Thấp (~KB stack) | Thấp | Thấp |
| Bẫy lớn nhất | Chặn loop, microtask starvation | Hết pm.max_children | Goroutine leak, data race | Pinning khi synchronized (chưa verify) | Gọi thư viện chặn trong async |
| Tuyển người | Rất dễ (JS/TS phổ biến) | Dễ | Trung bình | Dễ | Dễ |
Dùng Node khi: service chủ yếu chờ I/O, nhiều kết nối đồng thời, realtime, BFF cho frontend cùng stack TypeScript. Tránh khi: phần lớn thời gian là tính toán CPU trong request (xử lý ảnh/video, ML, báo cáo tổng hợp lớn) — hoặc team không đủ kỷ luật để giữ mọi callback ngắn.
8. Góc nhìn tech lead: tác động tổ chức
- Kỷ luật code là bắt buộc, không phải tuỳ chọn. Một dev thêm
fs.readFileSynchayJSON.parsefile 50MB vào một handler là đủ làm chậm cả service. Đưa vào checklist review: "handler này có việc CPU O(n) theo input người dùng không?". Bật ESLintn/no-synccho code chạy trong request. - Metric event loop delay là metric hạng nhất, cùng hàng với latency và error rate — không có nó thì mọi sự cố "chậm" đều là đoán mò.
- Thiết kế healthcheck: liveness probe không nên timeout quá chặt; một lần GC dài hoặc một request nặng không nên làm pod bị giết. Readiness mới là chỗ phản ánh quá tải.
- Onboarding: dev từ PHP/Java quen "mỗi request một thread" — cần buổi riêng về event loop, nếu không sẽ viết code chặn mà không biết.
- Exit strategy: event loop không phải lock-in (khoá chặt vào vendor) — nó là mô hình. Khi một phần hệ thống thành CPU-bound, tách phần đó thành service riêng (Go/Rust/Java) sau queue; phần I/O vẫn giữ Node. Viết ADR ghi rõ tiêu chí tách: ELU > 0.8 kéo dài dù đã scale ngang.
9. Anti-patterns và bài học thực tế
setTimeout(fn, 0)để "nhường" CPU trong vòng lặp dài — được nhưng mỗi lần tốn ≥ 1ms và phải đi hết vòng.setImmediateđúng ý hơn (chạy ngay sau poll). Tốt nhất: đẩy việc nặng ra worker.- Viết test/logic dựa vào thứ tự timeout vs immediate ở top-level — flaky 10% như lab 5.2.
setIntervalcho job có I/O mà không chặn chồng lượt — job lần trước chưa xong lần sau đã chạy. DùngsetTimeoutđặt lại sau khi xong, hoặc scheduler thật.- Hẹn giờ nghiệp vụ bằng timer trong process (5.5) — tràn 24,8 ngày và mất khi restart.
- Tin rằng "async = không chặn". Một hàm
asyncchạy vòng for 1 triệu phần tử vẫn chặn —asyncchỉ nói kết quả trả về là Promise. - Quên unref job nền → shutdown treo (5.4).
Postmortem công khai: sự cố Cloudflare ngày 2019-07-02 do một regex backtracking (ReDoS) làm CPU 100% trên toàn bộ edge — không phải Node, nhưng đúng cơ chế "một đoạn tính toán chặn tất cả". Node còn tệ hơn vì chỉ có một thread: một regex như vậy làm đứng cả process (lab ReDoS ở bài 4). Nguồn: blog.cloudflare.com, "Details of the Cloudflare outage on July 2, 2019".
10. Câu hỏi phụ interviewer hay hỏi tiếp
- Node có đơn luồng không?
- JS chạy trên một thread. Process Node có nhiều thread: thread pool libuv (mặc định 4), thread nền của V8 (GC, compile). Lab ở bài 1 đếm được 7 thread lúc khởi động, 11 sau lần đọc file đầu tiên.
- setImmediate vs setTimeout(0) — cái nào chạy trước?
- Trong callback I/O: luôn setImmediate. Ở main module: không xác định, phụ thuộc đã trôi qua 1ms chưa khi vào loop.
- process.nextTick có phải một phase không?
- Không. Nó là hàng đợi của Node, được xả sau mỗi callback (trước cả Promise trong CommonJS). Vì không phải phase nên đệ quy nó chặn cả loop.
- Poll phase chờ bao lâu?
- Tới timer gần nhất (-1 = chờ mãi nếu không có timer). Timeout = 0 nếu còn setImmediate, còn pending callback hay handle đang đóng. Chứng minh bằng strace
epoll_pwait(…, 971). - Event loop Node khác browser thế nào?
- Browser có task queue + microtask + render steps, không có phase như libuv. Từ Node 11, microtask được xả sau mỗi timer/immediate — giống browser; trước đó Node xả sau cả một phase.
- Làm sao biết loop đang bị chặn trong production?
monitorEventLoopDelay(p99, max) + ELU; chụp CPU profile khi max vượt ngưỡng.- Timers ở đầu hay cuối vòng?
- Về khái niệm: đầu vòng. Trong libuv ≥ 1.45: chạy một lần trước khi vào vòng, rồi ở cuối mỗi vòng. Thứ tự tương đối giữa các phase không đổi.
Phụ lục: lab đã chạy
Môi trường: Node v22.23.1 (libuv 1.51.0, V8 12.4.254.21), Linux 6.8.0, 4 vCPU, 7.8GB RAM,
máy dùng chung (load dao động). strace 6.x. Toàn bộ script nằm trong một thư mục tạm; chép
đoạn code trong bài ra file là chạy được, không cần package ngoài.
# thứ tự timeout vs immediate, 1000 lần
for i in $(seq 1000); do node 01-timeout-vs-immediate.js | head -1; done | sort | uniq -c
for i in $(seq 1000); do node 02-inside-io.js | head -1; done | sort | uniq -c
# poll phase ngủ bao lâu (bỏ dòng của epoll fd 3 — là của thread khác)
strace -e trace=epoll_pwait -T node 06-poll-sleep.js 2>&1 | grep -v "^epoll_pwait(3,"
strace -e trace=epoll_pwait -T node 06b-immediate-nosleep.js 2>&1 | grep -v "^epoll_pwait(3,"
# ECONNREFUSED đi qua poll trên Linux
strace -f -e trace=connect,epoll_pwait node 07-pending.js
# source để đọc
curl -sL https://raw.githubusercontent.com/libuv/libuv/v1.51.0/src/unix/core.c | grep -n "int uv_run" -A 60
// 06-poll-sleep.js
setTimeout(() => console.log('timer 1000ms'), 1000);
setTimeout(() => console.log('timer 1500ms'), 1500);
// 06b-immediate-nosleep.js
setTimeout(() => console.log('timer 1000ms'), 1000);
let n = 0;
(function again() { if (++n < 3) setImmediate(again); })();
// 05-immediate-nested.js
setImmediate(() => {
console.log('immediate 1');
setImmediate(() => console.log('immediate lồng (vòng sau)'));
setTimeout(() => console.log('timeout 0 đặt trong check'), 0);
process.nextTick(() => {
console.log('tick 1');
process.nextTick(() => console.log('tick lồng (ngay trong cùng đợt)'));
});
});
setImmediate(() => console.log('immediate 2'));
// 16-what-keeps-alive.js (rút gọn)
const srv = http.createServer((q, s) => s.end('ok')).listen(0, '127.0.0.1', async () => {
/* gửi 1 request keep-alive */
setInterval(() => {}, 60_000); // job định kỳ quên unref
srv.close(() => console.log('server closed'));
setTimeout(() => { console.log('còn giữ loop:', process.getActiveResourcesInfo()); process.exit(0); }, 200).unref();
});
Chưa verify: callback nào thật sự đi qua uv__run_pending (cần gdb/uprobe
vào libuv); hành vi timer khi đổi giờ hệ thống; hành vi trên macOS/Windows; bản Node < 20 (libuv cũ,
timers ở đầu vòng).