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.nextTick là 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 docs design.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ì:

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

  1. 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.
  2. 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.nextTick rồi hàng microtask (Promise), sau đó mới quay lại libuv.
Vòng event loop 6 phase Một chấm tròn chạy quanh vòng, lần lượt đi qua timers, pending callbacks, idle/prepare, poll, check, close callbacks. Ở giữa có hàng microtask nhấp nháy, chạy sau mỗi callback. 1. timers setTimeout / setInterval 2. pending callbacks callback I/O bị hoãn 3. idle, prepare nội bộ Node 4. poll epoll_wait: chờ + chạy callback I/O 5. check setImmediate 6. close callbacks socket.on('close') sau MỖI callback JS: ① nextTick queue (hết) ② microtask queue (hết) loop còn handle/request đang ref? còn → vòng tiếp · hết → process thoát
Hình 1 — Một vòng event loop. Chấm xanh đi qua 6 phase theo thứ tự cố định. Khung vàng ở giữa không phải là một phase: nó chạy xen vào sau từng callback ở mọi phase. Poll (viền xanh lá) là phase duy nhất loop được phép ngủ để chờ I/O.

Hệ quả trực tiếp của hai câu trên — tất cả đều được lab ở phần sau:

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_timers nằ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ào while (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òng for r < 8), không phải chờ vòng sau.

3.2 Từng phase làm gì

PhaseHàm libuvJS nào rơi vào đâyGhi chú
timersuv__run_timerssetTimeout, setIntervalNode chỉ dùng 1 uv_timer cho toàn bộ timer JS (xem 3.3).
pending callbacksuv__run_pendingGần như không thấy từ JSCallback 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, prepareuv__run_idle, uv__run_prepareKhông có API JSNode dùng idle handle để cấm poll ngủ khi còn setImmediate (3.5).
polluv__io_poll → epoll_pwaitCallback socket, HTTP, kết quả fs/dns/crypto từ thread poolPhase duy nhất loop được ngủ. Timeout tính từ timer gần nhất (3.4).
checkuv__run_checksetImmediateImmediate đặt trong check chạy ở vòng sau.
close callbacksuv__run_closing_handles'close' của socket/server khi đóngCallback 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:

Đá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
Poll phase ngủ tới timer gần nhất Trục thời gian 0 đến 1500ms. Loop ngủ trong epoll_pwait 971ms tới lúc timer 1000ms, chạy callback, rồi ngủ tiếp 482ms tới timer 1500ms. 0 1000ms 1500ms poll epoll_pwait(timeout=971) timeout=482 timers: callback 1000ms timers: callback 1500ms bây giờ
Hình 2 — Không có I/O nào tới thì process không tốn CPU: nó ngủ trong kernel đúng bằng khoảng thời gian tới timer gần nhất (số lấy từ strace ở trên). Nếu giữa chừng có gói tin tới socket, 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ôngLab: 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ôngnextTick/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
Callback chặn loop làm timer trễ Timer 10ms đã tới hạn nhưng phải chờ vì callback readFile đang chạy vòng while 50ms trên main thread. Timer chỉ chạy lúc 55ms. 0 10ms 52ms main readFile callback: while (…) 50ms timer tới hạn… nhưng phải chờ chạy lúc 55ms
Hình 3 — Không có preemption: timer tới hạn ở 10ms nhưng main thread đang bận trong callback khác. Nó chỉ được chạy khi callback kia trả quyền về loop. Trong server thật, "timer" ở đây có thể là healthcheck, timeout của socket, hay request của khách hàng khác.

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ằng Date.now() 5ms vẫn còn 10/1000 lần immediate thắng; đổi sang performance.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 do Date.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ùng performance.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

MetricLấy từ đâuNgưỡng tham khảoÝ nghĩa
Event loop delay p99/maxperf_hooks.monitorEventLoopDelay()p99 < 50ms; max > 200ms là có callback chặnLoop 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 / resourcesprocess.getActiveResourcesInfo()Tăng dần không giảm = ròSocket/timer bị bỏ quên.
Heap, GC pauseprocess.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:

  1. 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.
  2. ELU cao + CPU 100% một core → chụp CPU profile: node --cpu-prof hoặc gửi SIGUSR1 để mở inspector rồi profile bằng Chrome DevTools.
  3. ELU thấp mà fs/dns/crypto chậm → nghẽn thread pool (bài 4).
  4. Không thoát khi deploy → process.getActiveResourcesInfo().

7. So sánh và ma trận quyết định

TrụcNode.js event loopPHP-FPM (process/request)Go (goroutine)Java 21+ virtual threadPython asyncio
Mô hình1 thread JS + event loopMỗi request một process, code chặnM:N thread, runtime tự lên lịchM:N thread, code chặn "giả"1 thread + event loop
Code I/O viết kiểuasync/awaitTuần tựTuần tựTuần tựasync/await
Việc CPU nặngChặn cả processChỉ chặn 1 workerRuntime cắt ngang được (preempt)Tận dụng nhiều coreChặn cả loop (+ GIL)
Dùng nhiều corePhải tự: cluster / nhiều pod / worker_threadsSẵn (nhiều process)SẵnSẵnPhả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ấpThấp
Bẫy lớn nhấtChặn loop, microtask starvationHết pm.max_childrenGoroutine leak, data racePinning khi synchronized (chưa verify)Gọi thư viện chặn trong async
Tuyển ngườiRất dễ (JS/TS phổ biến)DễTrung bìnhDễ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

9. Anti-patterns và bài học thực tế

  1. 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.
  2. Viết test/logic dựa vào thứ tự timeout vs immediate ở top-level — flaky 10% như lab 5.2.
  3. setInterval cho 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ùng setTimeout đặt lại sau khi xong, hoặc scheduler thật.
  4. Hẹn giờ nghiệp vụ bằng timer trong process (5.5) — tràn 24,8 ngày và mất khi restart.
  5. Tin rằng "async = không chặn". Một hàm async chạy vòng for 1 triệu phần tử vẫn chặn — async chỉ nói kết quả trả về là Promise.
  6. 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).