Skip to content

Event loop ​

Trang Process, thread & concurrency giải thích vì sao một thread có thể lo nhiều việc cùng lúc. Trang này đi vào cách Node.js làm điều đó: một request đi qua những bước nào, nhiều request xen kẽ nhau ra sao, event loop chạy callback theo thứ tự nào, và khi nào nó bị nghẽn. Số liệu trong trang là số đo thật bằng script lab/event-loop/ trên MacBook M2 với Node 24.

Bên trong một process Node.js ​

Có ba thành phần cần biết, gồm hai thư viện và một thread nơi cả hai chạy:

  • V8 chạy JavaScript, quản lý heap và GC.
  • libuv là thư viện C cài đặt event loop. Với mạng, nó dùng epoll (Linux), kqueue (macOS) hoặc IOCP (Windows) để hệ điều hành báo khi socket có dữ liệu hay thao tác đã xong. Với những việc hệ điều hành không có API non-blocking, nó dùng một thread pool, mặc định 4 thread.
  • Main thread chạy JavaScript của ứng dụng và chạy cả event loop. Toàn bộ handler, middleware, callback đều chạy trên thread này.
Main threadJavaScript của ứng dụng+ event loop (libuv)Kernel: epoll / kqueuesocket: HTTP, MySQL, RedisThread pool (4 thread)fs, dns.lookup, crypto, zlibđăng ký socketbáo socket sẵn sànggiao việcbáo xong
Chờ mạng không tốn thread nào, vì kernel canh socket thay cho Node. Chỉ một nhóm API nhất định chiếm thread của thread pool trong lúc chạy.

Đường đi của một request ​

Lấy một handler đọc database:

js
import http from 'node:http'
import { pool } from './db.js' // mysql2/promise

http.createServer(async (req, res) => {
  const [rows] = await pool.query('SELECT * FROM users WHERE id = ?', [1]) // bước 3
  res.end(JSON.stringify(rows))                                           // bước 5
}).listen(3000)
  1. Nhận connection. Client mở connection TCP. Kernel tự thực hiện bắt tay ba bước (three-way handshake) rồi đặt connection vào hàng đợi accept của socket đang lắng nghe. epoll báo có connection mới, event loop gọi callback của Node, Node nhận connection và đăng ký socket mới với epoll.
  2. Đọc request. Dữ liệu đến, epoll báo socket có dữ liệu. Node đọc, bộ parser HTTP (llhttp) tách header và body, rồi gọi handler. Từ lúc này code của ứng dụng chạy trên main thread.
  3. Gửi query. pool.query ghi câu SQL vào socket của một connection MySQL. Ghi không chặn, nên hàm trả về một Promise ngay. await tạm dừng handler, và main thread quay về event loop để làm việc khác, ví dụ đọc request tiếp theo.
  4. Nhận kết quả. MySQL trả kết quả, epoll báo socket của connection MySQL có dữ liệu. Driver đọc, parse, rồi resolve Promise. Phần code sau await được xếp vào hàng microtask và chạy ngay sau callback đó.
  5. Trả response. res.end ghi response vào socket, cũng không chặn, và kernel lo việc gửi đi.

Trong suốt quá trình, không thread nào đứng chờ MySQL. Bước nào cũng có một đoạn chạy trên main thread (nhận connection, parse request, gọi pool.query, xử lý kết quả, ghi response), nhưng mỗi đoạn thường dưới một ms. Trong lúc chờ MySQL thì main thread rảnh hoàn toàn đối với request này và phục vụ request khác.

Chạy từng bước để xem hai request xen kẽ trên một thread, và await biến phần code phía sau thành một microtask ra sao:

Kịch bản
Bước 1/33 · t = 0 ms
1http.createServer(async (req, res) => {2  console.log(req.id, 'nhận request lúc', now())3  const rows = await db.query(sql)        // MySQL trả lời sau 20 ms4  res.end(JSON.stringify(rows))5  console.log(req.id, 'trả response lúc', now())6}).listen(3000)7// client A gửi request lúc t = 0, client B lúc t = 2 ms
Main thread đang chạycode chính
Pha của event loop
  1. Code chính
  2. timers (một lần trước vòng đầu)
  3. pending callbacks, idle, prepare
  4. poll
  5. check
  6. close callbacks
  7. timers
  8. Hết việc, process thoát
Hàng nextTick
trống
Hàng microtask (Promise)
trống
Timers đã hẹn
trống
Hàng poll (I/O đã xong)
trống
Hàng check (setImmediate)
trống
Đang làm ngoài JavaScript
request A · kernel (client A), xong t = 0

Server đang lắng nghe. (Giả lập) Client A gửi request lúc t = 0 ms; kernel nhận connection và dữ liệu rồi báo cho event loop.

Console
(chưa in gì)

Hai request cùng đang xử lý trên một thread. B không phải chờ A xong, vì A nhả main thread ngay khi gặp await. Mục có viền đậm là mục vừa được thêm hoặc vừa đổi ở bước này. Mô phỏng theo thứ tự pha của Node 20.3 trở lên, code chạy dưới dạng CommonJS.

Mô phỏng dưới đây nhìn cùng chuyện đó theo trục thời gian, với nhiều request hơn:

Kịch bản

4 request đến cách nhau 5 ms. Mỗi request: 10 ms chạy handler, chờ database 40 ms, 5 ms trả kết quả.

70 mslatency của request chậm nhất
85 msxong tất cả
Main threadABCDABCDRequest A55 msRequest B60 msRequest C65 msRequest D70 ms0 ms50 ms100 ms
chạy JavaScript trên main threadchờ tới lượt (main thread hoặc thread pool đang bận)chờ database, không giữ thread nào

Trong lúc A chờ database, main thread chạy handler của B, C, D. Bốn request cùng đang xử lý mà chỉ có một thread, và không request nào phải chờ request khác chờ database.

Số ms là minh hoạ, mỗi ô 5 ms. Mô phỏng bỏ qua chi phí của chính event loop và giả định các thread pool chạy nhanh như nhau.

Một thread giữ được bao nhiêu request đang chờ ​

Đo thật với một server Node.js mà mỗi request chờ timer 1 giây rồi mới trả lời (giống chờ một query chậm). Client gửi cùng lúc N request, mỗi request trên một connection mới:

Số request cùng lúcTổng thời gianĐang chờ cùng lúc (max)RSS của serverSố thread của server
11.012–1.016 ms155 → 57–58 MB8
1.0001.100–1.181 ms1.00055 → 94–99 MB8
10.0001.736–5.143 ms9.804–10.00055 → 280–302 MB8

Nếu server phải xử lý lần lượt thì 10.000 request mất 10.000 giây. Thực tế phần lớn các lượt mất chưa tới 2,5 giây. Mỗi request đang chờ tốn khoảng 23–25 KB RAM cho socket, buffer và các object HTTP. Số thread của server giữ nguyên dù có 10.000 request đang dở. Server có 8 thread thay vì 7 như một process vừa khởi động, vì gọi listen với host làm Node nạp module node:dns, và đo trên macOS thì riêng việc nạp module này đã thêm một thread.

Ở mức 10.000, có lượt mất 5–8 giây. Lý do nằm ở kernel chứ không ở Node:

  • Server gọi listen({ backlog: 20000 }), nhưng macOS cắt backlog xuống kern.ipc.somaxconn. Xem bằng netstat -L thì hàng đợi accept tối đa chỉ có 128 connection.
  • 10.000 client cùng bắt tay một lúc làm hàng đợi đầy, và kernel bỏ gói SYN của những connection đến sau. Client phải gửi lại SYN sau khoảng 1 giây, rồi lâu hơn ở các lần sau.
  • Đo latency từng request ở các lượt chậm, phần chậm thêm dồn thành cụm quanh +0,9 s, +1,8 s, +3,7 s và +5,6 s, đúng các mốc gửi lại SYN. Cụm +1,8 s có đúng 128 request, bằng kích thước hàng đợi. Lượt chậm nhất có 198 connection bỏ cuộc với lỗi ETIMEDOUT.

Ngoài đời ít gặp chuyện 10.000 connection mới cùng mở trong một khoảnh khắc, vì client giữ connection lâu (keep-alive) và load balancer đứng trước server. Nhưng sau một lần deploy hay restart, khi mọi client cùng nối lại, chuyện này có thể xảy ra. Khi đó cần biết backlog thật do hệ điều hành quyết định, không phải con số truyền vào listen.

Các pha của event loop ​

Mỗi vòng của event loop đi qua các pha theo thứ tự, mỗi pha có hàng callback riêng:

PhaChạy gì
timersCallback của setTimeout, setInterval đã tới hạn
pending callbacksMột số callback I/O bị hoãn sang vòng sau, ví dụ lỗi ECONNREFUSED khi kết nối TCP mà một số hệ Unix chờ một nhịp mới báo
idle, prepareDùng nội bộ
pollLấy sự kiện I/O mới (socket có dữ liệu, file đọc xong) và chạy callback của chúng, tới khi hết hàng hoặc chạm giới hạn cứng của hệ thống. Nếu không còn gì để làm thì event loop chờ ở đây cho tới khi có sự kiện mới hoặc tới hạn timer gần nhất
checkCallback của setImmediate. Thực chất setImmediate là một loại timer đặc biệt mà libuv hẹn chạy ngay sau khi pha poll xong
close callbacksSự kiện 'close' khi socket hay handle bị đóng đột ngột (socket.destroy()). Đóng bình thường thì 'close' được phát qua process.nextTick

Mỗi pha có một hàng callback riêng, chạy theo thứ tự đến trước chạy trước.

Bảng liệt kê timers ở đầu theo cách tài liệu Node vẫn viết. Thứ tự chạy thật từ Node 20.3 (libuv 1.45) thì như sơ đồ dưới, vẽ lại theo sơ đồ trong tài liệu Node: libuv chạy timers một lần trước khi vào vòng đầu, rồi trong mỗi vòng thì timers chạy ở cuối, sau check và close callbacks. Trước Node 20.3, timers chạy ở đầu mỗi vòng. Code ứng dụng gần như không bị ảnh hưởng bởi thay đổi này, trừ khi phụ thuộc vào thứ tự chính xác giữa setImmediate và timer.

timersmột lần, trước vòng đầupending callbackscallback I/O hoãn từ vòng trướcidle, preparedùng nội bộpollnhận I/O; rảnh thì chờ ở đâychecksetImmediateclose callbackssocket.on('close')timerssetTimeout, setInterval tới hạnvòng tiếp theokernel báo: connection,dữ liệu mới, file đọc xongsau mỗi callbackchạy hết nextTick,rồi hết microtask
Event loop chỉ đứng chờ ở pha poll. Callback của setImmediate đặt trong callback I/O luôn chạy trước timer, vì pha check đến ngay sau poll, còn timers ở cuối vòng.

Event loop còn chạy chừng nào còn thứ giữ nó sống, như timer đang chờ, socket đang mở, server đang lắng nghe. Khi không còn thứ gì như vậy, process tự thoát. Gọi unref() trên timer hay socket cho phép process thoát dù thứ đó vẫn còn. Ví dụ timer gửi metric định kỳ thường được unref() để nó không giữ process sống sau khi server đã đóng.

setTimeout(fn, 0) thực chất là 1 ms, vì Node đặt mọi delay nhỏ hơn 1 thành 1.

Timer là ngưỡng, không phải giờ hẹn ​

setTimeout(fn, 100) nghĩa là fn chạy sớm nhất sau 100 ms, không phải đúng lúc 100 ms. Tài liệu Node nói thẳng rằng chính pha poll quyết định khi nào timer được chạy. Event loop chỉ quay lại timers khi pha poll và các callback đang chạy đã xong.

Đo lại ví dụ trong tài liệu Node: đặt timer 100 ms, đồng thời gửi một request tới server trả lời sau 95 ms, và callback nhận kết quả chạy CPU thêm 10 ms.

LượtI/O xong lúcTimer 100 ms chạy lúc
Không có I/O101 ms
Có I/O và callback 10 ms (3 lượt)97 ms107–108 ms

Lúc 100 ms thì timer đã tới hạn, nhưng main thread đang chạy callback 10 ms của request, nên timer phải chờ callback xong. Tài liệu Node tính ra 105 ms với I/O xong đúng lúc 95 ms; đo thật ra 107 ms vì request mất thêm khoảng 2 ms đi qua mạng loopback. Đây cũng là lý do không dùng setTimeout làm đồng hồ chính xác, ví dụ để đo thời gian hay hẹn giờ gửi tin. Lúc event loop bận, mọi timer đều trễ theo.

Chạy từng bước: Timer 100 ms chạy lúc 105 ms
Bước 1/11 · t = 0 ms
1const t0 = Date.now()2setTimeout(() => {3  console.log('timer:', Date.now() - t0)4}, 100)5http.get(url, (res) => {      // server trả lời sau 95 ms6  console.log('I/O:', Date.now() - t0)7  busy(10)                     // chạy CPU 10 ms8})
Main thread đang chạycode chính
Pha của event loop
  1. Code chính
  2. timers (một lần trước vòng đầu)
  3. pending callbacks, idle, prepare
  4. poll
  5. check
  6. close callbacks
  7. timers
  8. Hết việc, process thoát
Hàng nextTick
trống
Hàng microtask (Promise)
trống
Timers đã hẹn
timer 100 ms · t = 100
Hàng poll (I/O đã xong)
trống
Hàng check (setImmediate)
trống
Đang làm ngoài JavaScript
trống

setTimeout đăng ký timer, tới hạn lúc t = 100 ms. Callback chưa chạy.

Console
(chưa in gì)

Đo thật trên M2 ra 107 ms, vì request mất thêm khoảng 2 ms qua mạng loopback (xem bảng bên dưới). Mục có viền đậm là mục vừa được thêm hoặc vừa đổi ở bước này. Mô phỏng theo thứ tự pha của Node 20.3 trở lên, code chạy dưới dạng CommonJS.

Ngoài các pha còn có hai hàng chạy chen vào sau mỗi callback, trước khi event loop đi tiếp:

  • Hàng process.nextTick.
  • Hàng microtask, gồm .then của Promise, phần code sau await và queueMicrotask.

Node chạy hết hai hàng này rồi mới sang callback kế tiếp. Vì vậy một chuỗi nextTick hay Promise gọi đệ quy không bao giờ kết thúc sẽ làm event loop không tới được pha poll, và mọi I/O bị bỏ đói.

Chạy từng bước ​

Bấm Bước tiếp để xem từng dòng code đăng ký callback vào hàng nào, việc nào được giao ra ngoài JavaScript (kernel hay thread pool), event loop đi qua các pha ra sao, và lúc nào callback được lấy ra chạy. Bốn kịch bản ở đây tương ứng với các kết quả đo ở mục kế tiếp. Các mục khác trong trang cũng có mô phỏng cùng loại, đặt ngay cạnh phần chúng minh hoạ.

Kịch bản
Bước 1/36 · t = 0 ms
1console.log('sync 1')2setTimeout(() => console.log('timeout'), 0)3setImmediate(() => console.log('immediate'))4Promise.resolve().then(() => console.log('promise'))5process.nextTick(() => console.log('nextTick'))6fs.stat('.', () => {7  setTimeout(() => console.log('I/O: timeout'), 0)8  setImmediate(() => console.log('I/O: immediate'))9})10console.log('sync 2')
Main thread đang chạycode chính
Pha của event loop
  1. Code chính
  2. timers (một lần trước vòng đầu)
  3. pending callbacks, idle, prepare
  4. poll
  5. check
  6. close callbacks
  7. timers
  8. Hết việc, process thoát
Hàng nextTick
trống
Hàng microtask (Promise)
trống
Timers đã hẹn
trống
Hàng poll (I/O đã xong)
trống
Hàng check (setImmediate)
trống
Đang làm ngoài JavaScript
trống

console.log in “sync 1”.

Console
sync 1

Ở code chính, setTimeout 0 và setImmediate không có thứ tự cố định. Mô phỏng giả định code chính chạy xong trước khi timer 1 ms tới hạn, là trường hợp đo được 97–98/100 lần trên M2. Mục có viền đậm là mục vừa được thêm hoặc vừa đổi ở bước này. Mô phỏng theo thứ tự pha của Node 20.3 trở lên, code chạy dưới dạng CommonJS.

Thứ tự chạy, đo thật ​

Cùng một đoạn code (file lab/event-loop/order.cjs và order.mjs), chạy dưới hai dạng module:

CommonJS:  sync 1 → sync 2 → nextTick → Promise.then → queueMicrotask → setImmediate → setTimeout 0
ES module: sync 1 → sync 2 → Promise.then → queueMicrotask → nextTick → setImmediate → setTimeout 0

Phần tiếp theo giống nhau ở cả hai dạng. Hai nhãn I/O: … là callback được đặt từ bên trong callback của fs.stat:

timer A → promise của A → timer B → promise của B → I/O: setImmediate → I/O: setTimeout 0

Hai mục cuối của dòng đầu (setImmediate và setTimeout 0 ở code chính) không cố định. Có lượt chạy bản CommonJS cho ra setTimeout 0 → setImmediate.

Có bốn điều rút ra từ kết quả:

  • Code đồng bộ chạy hết trước, rồi mới tới mọi loại callback.
  • nextTick chạy trước Promise trong CommonJS, nhưng chạy sau trong ES module. Code chính của một ES module được chạy bên trong quá trình xử lý Promise của bộ nạp module, nên các microtask của Promise chạy hết trước khi Node tới hàng nextTick. Đừng viết code phụ thuộc vào thứ tự này.
  • Microtask chạy ngay sau từng callback, không chờ hết cả pha. Hai timer cùng hạn cho ra timer A → promise của A → timer B, không phải timer A → timer B → promise của A.
  • setTimeout(0) và setImmediate: trong callback của I/O, setImmediate luôn chạy trước, vì pha check nằm ngay sau poll. Ở code chính thì tài liệu Node nói thứ tự không xác định, tuỳ tốc độ khởi động của process. Đo 100 lần trên máy này, setImmediate chạy trước 97–98 lần, và chính file order.cjs cũng từng cho ra thứ tự ngược lại, nên đây không phải điều được đảm bảo.

Tên gọi của hai hàm này dễ gây nhầm. process.nextTick chạy ngay sau thao tác hiện tại, trước khi event loop đi tiếp, nên thực ra chạy sớm hơn setImmediate. Còn setImmediate phải chờ tới pha check. Tài liệu Node thừa nhận lẽ ra phải đổi tên cho nhau, nhưng đổi thì hỏng phần lớn package trên npm, nên tên sẽ giữ nguyên.

Khi nào dùng process.nextTick ​

nextTick có mặt vì một nguyên tắc thiết kế API: một hàm nhận callback thì nên luôn gọi callback bất đồng bộ, kể cả khi kết quả đã có sẵn. Nếu lúc thì gọi ngay, lúc thì gọi sau, người dùng hàm sẽ gặp lỗi khó đoán. Hai ví dụ trong tài liệu Node, đã chạy thử:

js
let bar = null
function syncApi(cb) { cb() }                    // trông như bất đồng bộ nhưng gọi callback ngay
function asyncApi(cb) { process.nextTick(cb) }   // luôn gọi callback sau khi code hiện tại chạy xong

syncApi(() => console.log(bar))   // in null: callback chạy trước dòng gán bên dưới
asyncApi(() => console.log(bar))  // in 1
bar = 1
js
class Job extends EventEmitter {
  constructor() {
    super()
    // this.emit('ready') ở đây thì không ai nghe được, vì .on('ready') chưa được gắn
    process.nextTick(() => this.emit('ready'))
  }
}
new Job().on('ready', () => console.log('ready')) // in ra nhờ nextTick

Chính Node cũng dùng cách này. Ví dụ server.listen(port) có thể mở cổng ngay lập tức, nhưng sự kiện 'listening' được phát qua nextTick để dòng server.on('listening') viết ngay sau vẫn kịp gắn.

Ngoài các trường hợp như vậy, tài liệu Node khuyên dùng setImmediate trong mọi trường hợp, vì dễ suy luận hơn và không làm bỏ đói I/O.

Thread pool của libuv ​

Theo tài liệu Node.js, các API sau chạy trên thread pool:

  • Mọi API của fs, trừ file watcher và các hàm …Sync.
  • Các hàm crypto bất đồng bộ: crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.randomFill(), crypto.generateKeyPair().
  • dns.lookup(), hàm mà http.request và fetch mặc định dùng để đổi hostname thành địa chỉ IP.
  • Mọi API của zlib, trừ các hàm …Sync.

Socket mạng (HTTP, driver mysql2, pg, Redis) không dùng thread pool. Chạy từng bước để xem việc thứ 5 và thứ 6 xếp hàng chờ thread trống:

Chạy từng bước: 6 lần pbkdf2, pool 4 thread
Bước 1/26 · t = 0 ms
1for (const user of ['A', 'B', 'C', 'D', 'E', 'F']) {2  crypto.pbkdf2(pw, salt, 1e6, 32, 'sha256', () => {  // ~50 ms trên một thread3    console.log(user, 'xong lúc', now())4  })5}
Main thread đang chạycode chính
Pha của event loop
  1. Code chính
  2. timers (một lần trước vòng đầu)
  3. pending callbacks, idle, prepare
  4. poll
  5. check
  6. close callbacks
  7. timers
  8. Hết việc, process thoát
Hàng nextTick
trống
Hàng microtask (Promise)
trống
Timers đã hẹn
trống
Hàng poll (I/O đã xong)
trống
Hàng check (setImmediate)
trống
Đang làm ngoài JavaScript
pbkdf2 A · thread pool, xong t = 50

Giao việc cho thread pool (thread 1/4), xong lúc t = 50 ms. Main thread không chờ.

Console
(chưa in gì)

Giả định mỗi lần băm mất 50 ms và 4 thread chạy nhanh như nhau. Đo thật với 8 lần băm ở bảng bên dưới. Mục có viền đậm là mục vừa được thêm hoặc vừa đổi ở bước này. Mô phỏng theo thứ tự pha của Node 20.3 trở lên, code chạy dưới dạng CommonJS.

Đo thật 8 lần crypto.pbkdf2 gửi cùng lúc, đồng thời đếm số lần một setInterval 10 ms kịp chạy để xem main thread có rảnh không. Dòng đầu là mốc so sánh lúc không làm gì, vì setInterval tự nó cũng trôi dần và chạy thiếu vài lượt. Kết quả bốn lượt (hai lượt đầu chưa có dòng mốc):

Cấu hìnhTừng việc xong lúcTổngsetInterval chạy được
Rảnh 230 ms (mốc so sánh)230–231 ms21 trên 23 lần
pbkdf2, pool 4 thread (mặc định)4 việc lúc 115–120 ms, 4 việc lúc 229–238 ms230–238 ms19–21 trên 23 lần
pbkdf2, pool 8 thread135–178 ms157–178 ms10–15 trên 15–17 lần
pbkdf2Sync trên main thread104–108 ms mỗi việc, nối tiếp nhau853–867 ms1 trên 85–86 lần

Bảng cho thấy bốn điều:

  • Pool 4 thread thì việc thứ 5 trở đi phải xếp hàng. 4 việc đầu xong lúc khoảng 115 ms, 4 việc sau phải chờ tới lượt nên xong lúc khoảng 230 ms.
  • Thêm thread không làm nhanh hơn tương ứng. Với 8 thread thì không việc nào phải chờ, nhưng mỗi việc chậm đi (135–178 ms so với 115 ms). Lý do là 8 thread tranh 8 core, và 4 trong số đó là core tiết kiệm điện chạy chậm hơn. Với việc tốn CPU như crypto và zlib, đặt UV_THREADPOOL_SIZE lớn hơn số core không làm nhanh hơn.
  • Bản bất đồng bộ không chặn main thread. Với pool 4 thread, timer chạy được 19–21 lượt, ngang mốc lúc rảnh, tức phần chạy thiếu là do bản thân setInterval. Với pool 8 thread, có lượt timer chạy thiếu thêm vài lần, vì 8 thread đang băm chiếm hết 8 core và main thread phải tranh CPU với chúng.
  • Bản …Sync chặn hẳn main thread. Trong 850 ms, timer chỉ chạy được một lần. Kernel vẫn nhận connection mới vào hàng đợi, nhưng server không xử lý được request nào khác trong khoảng đó.

Với việc chủ yếu là chờ, như fs trên disk chậm hay dns.lookup khi DNS server trả lời chậm, thread trong pool chủ yếu ngồi chờ chứ không tính toán. Lúc đó tăng UV_THREADPOOL_SIZE vượt số core vẫn có ích, vì nhiều thread cùng chờ được mà không tranh CPU.

Hệ quả thực tế là các API dùng thread pool dùng chung 4 thread. Nhiều request đăng nhập băm mật khẩu cùng lúc có thể làm chậm cả việc đọc file hay dns.lookup của những request không liên quan. UV_THREADPOOL_SIZE phải đặt bằng biến môi trường trước khi process khởi động (UV_THREADPOOL_SIZE=8 node app.js). Đặt process.env.UV_THREADPOOL_SIZE trong code thì không chắc có tác dụng, vì pool có thể đã được tạo trước khi code chạy.

Khi event loop bị chặn ​

Vì chỉ có một main thread, một callback chạy lâu làm mọi request khác phải chờ. Đo thật bằng cách gửi đều 100 request/giây vào /fast (trả lời ngay) trong 10 giây, rồi lặp lại với mỗi giây thêm một request vào /slow (chạy CPU 200 ms, giống JSON.parse một payload lớn):

Kịch bảnp50 /fastp99 /fastmax /fastEvent loop delay p99Event loop delay max
Chỉ có /fast1,7 ms3,8–3,9 ms5–11 ms2,5–2,6 ms2,7–9,6 ms
Thêm 1 request /slow mỗi giây1,7 ms190–192 ms199–202 ms191 ms200–201 ms

Mỗi giây chỉ có một request chậm, nhưng nó chiếm 20% thời gian của main thread. p50 không đổi, vì chỉ khoảng 20% request rơi vào lúc main thread bị chặn. Còn p99 của /fast tăng 50 lần, vì mọi request đến trong 200 ms đó đều phải đứng chờ. Event loop delay đo đúng con số này, nên đây là chỉ số nên đưa lên dashboard của mọi service Node.js.

Chạy từng bước để thấy request /fast đã tới kernel từ sớm nhưng phải chờ callback của /slow chạy xong:

Chạy từng bước: Request /slow chặn /fast
Bước 1/12 · t = 0 ms
1server.on('request', (req, res) => {2  if (req.url === '/slow') JSON.parse(big)   // CPU 100 ms3  res.end()4  console.log(req.url, 'trả lời lúc', now())5})6// client 1 gửi /slow lúc t = 0, client 2 gửi /fast lúc t = 2 ms
Main thread đang chạycode chính
Pha của event loop
  1. Code chính
  2. timers (một lần trước vòng đầu)
  3. pending callbacks, idle, prepare
  4. poll
  5. check
  6. close callbacks
  7. timers
  8. Hết việc, process thoát
Hàng nextTick
trống
Hàng microtask (Promise)
trống
Timers đã hẹn
trống
Hàng poll (I/O đã xong)
trống
Hàng check (setImmediate)
trống
Đang làm ngoài JavaScript
request /slow · kernel (client 1), xong t = 0

(Giả lập) Client 1 gửi /slow lúc t = 0 ms.

Console
(chưa in gì)

/fast đến lúc 2 ms nhưng trả lời lúc 100 ms. Event loop không ngắt được một callback đang chạy (cooperative). Mục có viền đậm là mục vừa được thêm hoặc vừa đổi ở bước này. Mô phỏng theo thứ tự pha của Node 20.3 trở lên, code chạy dưới dạng CommonJS.

Những thủ phạm hay gặp:

  • JSON.parse hoặc JSON.stringify dữ liệu lớn. Tài liệu Node đo một chuỗi JSON 50 MB mất khoảng 1,3 giây để parse.
  • Regex có lượng từ lồng nhau, ví dụ /(\/.+)+$/, gặp chuỗi được tạo ra để chạy rất lâu (ReDoS).
  • Các hàm …Sync: fs.readFileSync, crypto.pbkdf2Sync, zlib.gzipSync.
  • Vòng lặp trên mảng lớn, sắp xếp hay gom nhóm hàng trăm nghìn phần tử trong một request.
  • GC. Một lần GC lớn trên heap nhiều object sống lâu cũng dừng main thread, và event loop delay vọt lên dù code không làm gì nặng (Bộ nhớ & GC).

Cách xử lý chính là chia nhỏ việc để nhường event loop giữa các phần, hoặc đẩy việc sang worker_threads, process khác hay hàng đợi (Chặn event loop & xử lý CPU). Chia nhỏ việc nghĩa là xử lý một phần, rồi dùng setImmediate hẹn phần tiếp theo, để các callback I/O đang chờ được chạy xen vào giữa:

js
// Xử lý mảng lớn theo từng phần 1.000 phần tử
async function processInChunks(items, fn, size = 1000) {
  for (let i = 0; i < items.length; i += size) {
    items.slice(i, i + size).forEach(fn)
    await new Promise((resolve) => setImmediate(resolve)) // nhường event loop
  }
}

Dùng setImmediate chứ không dùng process.nextTick hay await Promise.resolve(), vì hai cách sau chạy trước khi event loop đi tiếp, nên không nhường được cho I/O. Hai kịch bản dưới làm cùng một việc 4 phần, mỗi phần 5 ms, trong lúc có dữ liệu từ socket về lúc 1 ms. Chạy bằng Node thật cũng ra như vậy: với nextTick, I/O được xử lý lúc khoảng 22 ms, sau khi cả 4 phần xong; với setImmediate, I/O được xử lý lúc 5 ms.

Kịch bản
Bước 1/15 · t = 0 ms
1socket.on('data', () => console.log('I/O lúc', now()))  // dữ liệu về lúc t = 1 ms2function work(i) {3  busy(5)                                 // mỗi phần 5 ms CPU4  if (i < 4) process.nextTick(() => work(i + 1))5  else console.log('4 phần xong lúc', now())6}7work(1)
Main thread đang chạycode chính
Pha của event loop
  1. Code chính
  2. timers (một lần trước vòng đầu)
  3. pending callbacks, idle, prepare
  4. poll
  5. check
  6. close callbacks
  7. timers
  8. Hết việc, process thoát
Hàng nextTick
trống
Hàng microtask (Promise)
trống
Timers đã hẹn
trống
Hàng poll (I/O đã xong)
trống
Hàng check (setImmediate)
trống
Đang làm ngoài JavaScript
socket có dữ liệu · kernel (socket), xong t = 1

Đăng ký nghe dữ liệu từ socket. (Giả lập) Dữ liệu về lúc t = 1 ms.

Console
(chưa in gì)

Dữ liệu về từ t = 1 ms nhưng tới t = 20 ms mới được xử lý, vì hàng nextTick phải chạy hết trước khi event loop tới pha poll. Mục có viền đậm là mục vừa được thêm hoặc vừa đổi ở bước này. Mô phỏng theo thứ tự pha của Node 20.3 trở lên, code chạy dưới dạng CommonJS.

Đo event loop ​

js
import { monitorEventLoopDelay, performance } from 'node:perf_hooks'

const RES = 10
const delay = monitorEventLoopDelay({ resolution: RES })
delay.enable()
let last = performance.eventLoopUtilization()

setInterval(() => {
  // Giá trị theo nanosecond; mỗi mẫu đã gồm cả resolution nên trừ đi
  const ms = (ns) => Math.max(0, ns / 1e6 - RES)
  const elu = performance.eventLoopUtilization(last)
  last = performance.eventLoopUtilization()
  console.log({
    delayP99: ms(delay.percentile(99)), // event loop trễ bao lâu so với lịch
    delayMax: ms(delay.max),
    elu: elu.utilization                 // tỉ lệ thời gian event loop bận, từ 0 đến 1
  })
  delay.reset()
}, 10_000)
  • Event loop delay cho biết một callback đến hạn phải chờ thêm bao lâu mới được chạy. Đây là phần thời gian xếp hàng mà log của handler không thấy (Latency).
  • Event loop utilization (ELU) là tỉ lệ thời gian event loop không ngồi chờ trong epoll. Chỉ số này giống độ bận trong đường cong độ bận và latency: ELU gần 1 thì latency vọt lên. Tài liệu Node lưu ý ELU khác CPU utilization. Một lệnh spawnSync('sleep', ['5']) làm ELU bằng 1 dù CPU gần như không làm gì, vì event loop bị chặn suốt 5 giây.

Câu hỏi phỏng vấn hay gặp ​

Node.js có phải single-thread không? JavaScript của ứng dụng chạy trên một thread, nhưng process có nhiều thread: các thread của V8 và thread pool 4 thread của libuv. Đo trên Node 24 ra 7 thread lúc khởi động trên macOS (13 trên Windows), và thêm 4 thread sau khi dùng thread pool lần đầu (Process, thread & concurrency).

Vì sao Node chịu được nhiều connection với một thread? Vì chờ mạng không giữ thread nào. Kernel canh socket bằng epoll/kqueue, còn main thread chỉ chạy những đoạn ngắn khi có dữ liệu. Một process giữ 10.000 request đang chờ với 8 thread và khoảng 23–25 KB cho mỗi request.

Khi nào Node không hợp? Khi mỗi request tốn nhiều CPU (xử lý ảnh, video, tính toán nặng), vì việc đó chặn mọi request khác. Lúc đó cần worker_threads hoặc tách sang service riêng.

process.nextTick khác setImmediate thế nào? nextTick chạy ngay sau thao tác hiện tại, trước mọi I/O. setImmediate chạy ở pha check, sau pha poll. Dùng nextTick đệ quy làm I/O bị bỏ đói, còn setImmediate thì không. nextTick dùng khi cần callback chạy sau code hiện tại nhưng trước mọi I/O, ví dụ để API luôn gọi callback bất đồng bộ hay để phát event sau khi người dùng kịp gắn listener. Còn lại thì tài liệu Node khuyên dùng setImmediate.

Tăng UV_THREADPOOL_SIZE có làm server nhanh hơn không? Chỉ có ích khi request đang xếp hàng chờ thread pool (fs, crypto, zlib, dns.lookup). Với việc tốn CPU như crypto và zlib, lợi ích dừng ở khoảng số core, vì thêm thread chỉ làm chúng tranh CPU với nhau. Với việc chủ yếu là chờ, như dns.lookup khi DNS chậm hay fs trên disk chậm, thread chỉ ngồi chờ, nên tăng vượt số core vẫn có ích. Nó không giúp gì cho query database, vì driver dùng socket chứ không dùng thread pool.

Ghi nhớ

  1. Main thread chạy JavaScript và event loop. Chờ mạng không tốn thread nào vì kernel canh socket thay cho Node, nên một process giữ được hàng nghìn request đang chờ.
  2. Mỗi await trả main thread về event loop. Request khác chạy trong lúc đó, nên đây vừa là lý do Node xử lý được nhiều request, vừa là chỗ sinh ra race condition.
  3. Sau mỗi callback, Node chạy hết nextTick và microtask rồi mới đi tiếp. Thứ tự nextTick với Promise khác nhau giữa CommonJS và ES module.
  4. fs, dns.lookup, crypto và zlib dùng chung thread pool 4 thread, nên việc thứ 5 phải xếp hàng. Với việc tốn CPU, tăng pool quá số core không làm nhanh hơn.
  5. Một callback tốn 200 ms CPU mỗi giây làm p99 của mọi request khác tăng lên khoảng 190 ms. Theo dõi event loop delay và ELU trên dashboard.

Nguồn tham khảo ​

Đã đối chiếu (đọc nguồn ngày 2026-10-20):

NguồnDùng cho phần
Node.js docs, The Node.js Event LoopSơ đồ và các pha, việc của từng pha (ECONNREFUSED ở pending callbacks, socket.destroy() ở close callbacks, giới hạn cứng của poll), poll chờ khi rảnh, timer là ngưỡng và ví dụ 95/105 ms, setImmediate với setTimeout ở code chính và trong I/O, nextTick chạy sau thao tác hiện tại và có thể bỏ đói I/O, vì sao có nextTick (ví dụ bar, EventEmitter, 'listening'), tên nextTick/setImmediate lẽ ra phải đổi cho nhau, khuyến nghị dùng setImmediate, thay đổi thứ tự timers từ libuv 1.45
Node.js v20.3.0 release noteslibuv 1.45.0 có từ Node 20.3.0
Node.js docs, TimersDelay nhỏ hơn 1 bị đặt thành 1 ms; unref() cho phép process thoát dù timer còn hoạt động
Node.js docs, Don't Block the Event LoopDanh sách API dùng thread pool, chia nhỏ việc và đẩy việc ra ngoài, ví dụ ReDoS, số đo JSON 50 MB
Node.js docs, UV_THREADPOOL_SIZEMặc định 4, danh sách API, đặt trong code không chắc có tác dụng
Node.js docs, perf_hooksmonitorEventLoopDelay (nanosecond, mỗi mẫu gồm cả resolution), eventLoopUtilization và ví dụ spawnSync
libuv docs, Design overviewNetwork I/O trên một thread bằng epoll/kqueue/IOCP, thread pool cho file, DNS và uv_queue_work, thứ tự các bước trong một vòng lặp

Kết quả tự chạy, chưa có tài liệu chính thức xác nhận:

  • Thứ tự nextTick và Promise trong ES module. Lời giải thích (code chính của ES module chạy trong quá trình xử lý Promise của bộ nạp module) là từ kiến thức chung.
  • Microtask chạy sau từng callback timer từ Node 11 (trước đó chạy sau cả nhóm timer) là từ kiến thức chung; kết quả đo trên Node 24 khớp.
  • Mức 23–25 KB cho mỗi request đang chờ tính từ RSS, gồm cả bộ nhớ chưa được GC thu lại.
  • http.request và fetch mặc định dùng dns.lookup.
  • Lượt 10.000 request chậm do SYN bị bỏ: đã đo netstat -L (backlog thật 128) và phân bố latency từng request (cụm quanh +0,9 s, +1,8 s, +3,7 s, +5,6 s; cụm +1,8 s có 128 request). Lịch gửi lại SYN chính xác của macOS chưa đối chiếu tài liệu. Bộ đếm netstat -s của macOS không đổi sau phép đo, có lẽ vì không tính traffic loopback, nên không dùng làm bằng chứng được.
  • Thread thứ 8 của server: đo được là xuất hiện khi nạp module node:dns (gọi listen với host thì nạp module này). Thread đó là gì bên trong (nhiều khả năng do c-ares, thư viện DNS của Node) thì chưa kiểm.
  • Khi phép đo 10.000 request chạy lặp liên tục, cổng tạm ở TIME_WAIT (khoảng 30 giây trên macOS, net.inet.tcp.msl = 15 s) có thể cạn và mọi request lỗi; script không bị ảnh hưởng khi chạy một lần.

Code đã dùng để chạy thử ​

Chạy bằng npm run lab -- event-loop (chỉ cần Node.js, khoảng 1 phút). Số trong trang lấy từ hai lượt chạy ngày 2026-10-20 trên MacBook M2 (Node 24.14, libuv 1.51). Bảng thread pool có bốn lượt; bảng 10.000 request có năm lượt cùng 11 lượt đo riêng phân bố latency khi kiểm giả thuyết SYN bị bỏ; xem Lab. Phần gửi request dùng lại lab/latency/util.mjs ở trang Latency.

lab/event-loop/server.mjs: server với /fast, /slow, /wait
js
// Server HTTP cho trang nodejs/event-loop, chạy trong process con. Ba route:
//   /fast  trả lời ngay
//   /slow  chạy CPU 200 ms rồi mới trả lời (giống JSON.parse một payload lớn)
//   /wait  chờ timer 1 giây rồi trả lời (giống chờ một query chậm), không tốn CPU
// Nhận message 'stats' qua IPC thì gửi lại RSS, số thread, event loop delay rồi reset.
import http from 'node:http'
import { monitorEventLoopDelay, performance } from 'node:perf_hooks'
import { countThreads } from '../common/threads.mjs'

const delay = monitorEventLoopDelay({ resolution: 10 })
delay.enable()

let served = 0, inFlight = 0, maxInFlight = 0
const server = http.createServer((req, res) => {
  served++
  if (req.url === '/slow') {
    const t0 = performance.now()
    while (performance.now() - t0 < 200) {}
    return res.end('slow')
  }
  if (req.url === '/wait') {
    inFlight++
    maxInFlight = Math.max(maxInFlight, inFlight)
    return setTimeout(() => {
      inFlight--
      res.end('waited')
    }, 1000)
  }
  res.end('fast')
})
server.keepAliveTimeout = 60_000
server.maxConnections = 50_000
server.listen({ port: 0, host: '127.0.0.1', backlog: 20_000 }, () => process.send({ port: server.address().port }))

process.on('message', (m) => {
  if (m !== 'stats') return
  // Mỗi mẫu đã gồm cả resolution (10 ms), trừ đi để ra phần event loop thật sự bị trễ.
  const ms = (ns) => Math.max(0, ns / 1e6 - 10)
  process.send({
    served, maxInFlight, rss: process.memoryUsage().rss, threads: countThreads(),
    delay: { p50: ms(delay.percentile(50)), p99: ms(delay.percentile(99)), max: ms(delay.max) }
  })
  delay.reset()
  maxInFlight = 0
})
lab/event-loop/order.cjs: thứ tự chạy callback (file order.mjs giống hệt, khác dòng import)
js
// Thứ tự chạy callback, chạy dưới dạng CommonJS. File order.mjs giống hệt nhưng là ES module.
const fs = require('node:fs')
const log = []
const out = (s) => log.push(s)

out('sync 1')
setTimeout(() => out('setTimeout 0'), 0)
setImmediate(() => out('setImmediate'))
Promise.resolve().then(() => out('Promise.then'))
queueMicrotask(() => out('queueMicrotask'))
process.nextTick(() => out('nextTick'))
out('sync 2')

// Hai timer cùng hạn, mỗi timer tạo một microtask: microtask chạy ngay sau từng callback.
setTimeout(() => {
  out('timer A')
  Promise.resolve().then(() => out('promise của A'))
}, 5)
setTimeout(() => {
  out('timer B')
  Promise.resolve().then(() => out('promise của B'))
}, 5)

// Trong callback của I/O: setImmediate luôn chạy trước setTimeout 0.
setTimeout(() => {
  fs.stat('.', () => {
    setTimeout(() => out('I/O: setTimeout 0'), 0)
    setImmediate(() => out('I/O: setImmediate'))
  })
}, 20)

setTimeout(() => console.log('CommonJS: ' + log.join(' → ')), 100)
lab/event-loop/order-race.mjs: setTimeout 0 với setImmediate ở code chính
js
// Ở code chính của module (ngoài mọi callback I/O), setTimeout 0 và setImmediate cái nào chạy trước
// tuỳ vào việc process khởi động nhanh hay chậm. Chạy 100 process để đếm.
import { spawnSync } from 'node:child_process'

const code = "setTimeout(() => console.log('timeout'), 0); setImmediate(() => console.log('immediate'))"
const count = { timeout: 0, immediate: 0 }
for (let i = 0; i < 100; i++) {
  const first = spawnSync(process.execPath, ['-e', code]).stdout.toString().split('\n')[0]
  count[first]++
}
console.log(`\nsetTimeout 0 với setImmediate ở code chính, 100 lần chạy: setTimeout trước ${count.timeout} lần, setImmediate trước ${count.immediate} lần`)
lab/event-loop/timer-threshold.mjs: timer 100 ms chạy lúc nào khi có callback I/O
js
// Timer chỉ là ngưỡng tối thiểu (theo ví dụ trong tài liệu Node "The Node.js Event Loop").
// Đặt setTimeout 100 ms. Cùng lúc gửi một request tới server (process con) trả lời sau 95 ms;
// callback nhận kết quả chạy CPU thêm 10 ms. Timer chỉ chạy được sau khi callback đó xong, tức khoảng 105 ms.
// Lượt so sánh: không có request, timer chạy gần đúng 100 ms.
import { fork } from 'node:child_process'
import http from 'node:http'
import { writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

const serverFile = join(tmpdir(), 'lab-timer-threshold-server.mjs')
writeFileSync(serverFile, `
  import http from 'node:http'
  const s = http.createServer((q, r) => setTimeout(() => r.end('ok'), 95))
  s.listen(0, '127.0.0.1', () => process.send(s.address().port))
`)
const child = fork(serverFile)
const port = await new Promise((r) => child.once('message', r))
const agent = new http.Agent({ keepAlive: true })
await new Promise((r) => http.get({ port, host: '127.0.0.1', agent }, (res) => res.resume().on('end', r))) // mở sẵn connection

function run(withIo) {
  return new Promise((resolve) => {
    const t0 = performance.now()
    let ioAt = null
    setTimeout(() => resolve({ timer: performance.now() - t0, ioAt }), 100)
    if (!withIo) return
    http.get({ port, host: '127.0.0.1', agent }, (res) => {
      res.resume().on('end', () => {
        ioAt = performance.now() - t0
        const s = performance.now()
        while (performance.now() - s < 10) {} // callback chạy CPU 10 ms
      })
    })
  })
}

console.log('\n=== Timer 100 ms là ngưỡng, không phải giờ hẹn chính xác ===')
for (const withIo of [false, true, true, true]) {
  const r = await run(withIo)
  console.log(withIo
    ? `có I/O xong lúc ${r.ioAt.toFixed(1).replace('.', ',')} ms và callback 10 ms: timer chạy lúc ${r.timer.toFixed(1).replace('.', ',')} ms`
    : `không có I/O:                                timer chạy lúc ${r.timer.toFixed(1).replace('.', ',')} ms`)
}
agent.destroy()
child.kill()
lab/event-loop/threadpool.mjs
js
// Thread pool của libuv. 8 lần crypto.pbkdf2 cùng lúc, với thread pool 4 thread (mặc định) và 8 thread.
// Đồng thời đếm số lần một setInterval 10 ms kịp chạy, để xem main thread có rảnh không.
// So với pbkdf2Sync: chạy ngay trên main thread nên chặn event loop.
// UV_THREADPOOL_SIZE phải đặt trước khi pool được tạo, nên mỗi cấu hình chạy trong một process riêng.
import { spawnSync } from 'node:child_process'

const CHILD = `
  const { pbkdf2, pbkdf2Sync } = require('node:crypto')
  const { performance } = require('node:perf_hooks')
  const ITER = 1_000_000 // khoảng 100 ms mỗi lần trên M2
  const mode = process.argv[1]
  pbkdf2Sync('pw', 'salt', 1000, 32, 'sha256') // khởi động
  let ticks = 0
  const timer = setInterval(() => ticks++, 10)
  const t0 = performance.now()
  const done = []
  if (mode === 'idle') {
    // Mốc so sánh: không làm gì trong 230 ms, xem setInterval tự nó chạy được bao nhiêu lượt.
    setTimeout(() => { done.push(performance.now() - t0); finish() }, 230)
  } else if (mode === 'sync') {
    for (let i = 0; i < 8; i++) { pbkdf2Sync('pw', 'salt' + i, ITER, 32, 'sha256'); done.push(performance.now() - t0) }
    finish()
  } else {
    let left = 8
    for (let i = 0; i < 8; i++) pbkdf2('pw', 'salt' + i, ITER, 32, 'sha256', () => { done.push(performance.now() - t0); if (--left === 0) finish() })
  }
  function finish() {
    // Chờ thêm một nhịp để timer kịp chạy nếu event loop vừa được giải phóng.
    setTimeout(() => {
      clearInterval(timer)
      const total = done[done.length - 1]
      console.log(JSON.stringify({ done: done.map(Math.round), total: Math.round(total), ticks, expected: Math.floor(total / 10) }))
    }, 0)
  }
`

console.log('\n=== Thread pool của libuv: 8 lần crypto.pbkdf2 cùng lúc ===')
console.log('cấu hình                          xong lúc (ms, từng việc)                 tổng     setInterval 10 ms chạy được')
for (const [name, mode, size] of [
  ['rảnh 230 ms (mốc so sánh)', 'idle', '4'],
  ['pbkdf2, pool 4 thread (mặc định)', 'async', '4'],
  ['pbkdf2, pool 8 thread', 'async', '8'],
  ['pbkdf2Sync trên main thread', 'sync', '4']
]) {
  const r = spawnSync(process.execPath, ['-e', CHILD, mode], { env: { ...process.env, UV_THREADPOOL_SIZE: size } })
  const o = JSON.parse(r.stdout.toString())
  console.log(`${name.padEnd(33)} ${o.done.join(', ').padEnd(40)} ${String(o.total).padStart(4)} ms  ${o.ticks}/${o.expected} lần`)
}
lab/event-loop/blocking.mjs: event loop bị chặn
js
// Một request tốn CPU làm chậm mọi request khác trên cùng process.
// Gửi đều 100 request/giây vào /fast trong 10 giây. Lượt hai gửi thêm mỗi giây một request vào /slow (CPU 200 ms).
import { startServer } from './common.mjs'
import { openLoop, get, summary, ms, sleep } from '../latency/util.mjs'

const SECONDS = 10
console.log(`\n=== Event loop bị chặn: 100 request/giây vào /fast trong ${SECONDS} s ===`)
console.log('kịch bản                          p50 /fast  p99 /fast  max /fast  event loop delay p99  max')
for (const withSlow of [false, true]) {
  const srv = await startServer()
  await openLoop(srv, { rate: 100, seconds: 1, path: '/fast' }) // khởi động
  await srv.stats()
  let slowTimer
  if (withSlow) slowTimer = setInterval(() => get(srv, undefined, '/slow'), 1000)
  const r = summary(await openLoop(srv, { rate: 100, seconds: SECONDS, path: '/fast' }))
  clearInterval(slowTimer)
  await sleep(300)
  const s = await srv.stats()
  const name = withSlow ? 'thêm 1 request /slow mỗi giây' : 'chỉ có /fast'
  console.log([name.padEnd(32), ms(r.p50).padStart(9), ms(r.p99).padStart(10), ms(r.max).padStart(10),
    (ms(s.delay.p99) + ' ms').padStart(21), (ms(s.delay.max) + ' ms').padStart(8)].join('  '))
  srv.stop()
}
lab/event-loop/connections.mjs: 10.000 request trên một thread
js
// Một thread JavaScript giữ được bao nhiêu request đang chờ cùng lúc.
// Client gửi cùng lúc N request vào /wait (server chờ timer 1 giây rồi mới trả lời).
// Nếu server xử lý lần lượt thì mất N giây; nếu xen kẽ được thì mất khoảng 1 giây.
//
// Ở mức 10.000, có lượt mất 5–8 giây. Lý do: macOS cắt backlog của listen() xuống kern.ipc.somaxconn
// (128), hàng đợi accept đầy thì kernel bỏ gói SYN, client phải gửi lại SYN sau khoảng 1, 2, 4… giây,
// và có connection bỏ cuộc (ETIMEDOUT). Script đếm số request lỗi và số request chậm hơn request nhanh
// nhất cùng lượt từ 0,7 giây trở lên, thay vì để một lỗi làm dừng cả script.
// Mỗi request mở một connection mới; chạy lại liên tục thì cổng tạm còn ở TIME_WAIT (~30 s trên macOS)
// có thể cạn, nên chờ khoảng nửa phút giữa hai lần chạy.
import { startServer } from './common.mjs'
import { get, now } from '../latency/util.mjs'

const mb = (b) => (b / 1024 / 1024).toFixed(0) + ' MB'
console.log('\n=== Nhiều request cùng chờ trên một thread ===')
console.log('số request  tổng thời gian  đang chờ cùng lúc (max)  RSS server         thread  lỗi  chậm thêm ≥ 0,7 s')
for (const n of [1, 1000, 10_000]) {
  const srv = await startServer()
  const before = await srv.stats()
  const t0 = now()
  let errors = 0
  const lat = await Promise.all(Array.from({ length: n }, () => {
    const s = now()
    return get(srv, undefined, '/wait').then(() => now() - s, () => (errors++, -1))
  }))
  const total = now() - t0
  const s = await srv.stats()
  const ok = lat.filter((x) => x >= 0)
  const late = ok.filter((x) => x - Math.min(...ok) >= 700).length
  console.log([String(n).padStart(10), (Math.round(total) + ' ms').padStart(14), String(s.maxInFlight).padStart(24),
    `${mb(before.rss)} → ${mb(s.rss)}`.padStart(17), String(s.threads).padStart(6), String(errors).padStart(4), String(late).padStart(18)].join('  '))
  srv.stop()
}
lab/event-loop/common.mjs
js
// Dựng server con của trang này và hỏi số liệu của nó. Phần gửi request dùng lại lab/latency/util.mjs.
import http from 'node:http'
import { fork } from 'node:child_process'

export async function startServer() {
  const child = fork(new URL('./server.mjs', import.meta.url), [], { stdio: 'inherit' })
  const { port } = await new Promise((r) => child.once('message', r))
  const agent = new http.Agent({ keepAlive: true, maxSockets: Infinity })
  const stats = () =>
    new Promise((r) => {
      child.once('message', r)
      child.send('stats')
    })
  return { port, agent, stats, stop: () => (agent.destroy(), child.kill()) }
}