Giao diện
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.
Đườ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)- 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.
- Đọ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.
- Gửi query.
pool.queryghi 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.awaittạm dừng handler, và main thread quay về event loop để làm việc khác, ví dụ đọc request tiếp theo. - 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 đó. - Trả response.
res.endghi 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
- Code chính
- timers (một lần trước vòng đầu)
- pending callbacks, idle, prepare
- poll
- check
- close callbacks
- timers
- 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ả
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úc | Tổng thời gian | Đang chờ cùng lúc (max) | RSS của server | Số thread của server |
|---|---|---|---|---|
| 1 | 1.012–1.016 ms | 1 | 55 → 57–58 MB | 8 |
| 1.000 | 1.100–1.181 ms | 1.000 | 55 → 94–99 MB | 8 |
| 10.000 | 1.736–5.143 ms | 9.804–10.000 | 55 → 280–302 MB | 8 |
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ốngkern.ipc.somaxconn. Xem bằngnetstat -Lthì 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:
| Pha | Chạy gì |
|---|---|
| timers | Callback của setTimeout, setInterval đã tới hạn |
| pending callbacks | Mộ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, prepare | Dùng nội bộ |
| poll | Lấ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 |
| check | Callback 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 callbacks | Sự 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.
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ượt | I/O xong lúc | Timer 100 ms chạy lúc |
|---|---|---|
| Không có I/O | 101 ms | |
| Có I/O và callback 10 ms (3 lượt) | 97 ms | 107–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
- Code chính
- timers (một lần trước vòng đầu)
- pending callbacks, idle, prepare
- poll
- check
- close callbacks
- timers
- 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
.thencủa Promise, phần code sauawaitvà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
- Code chính
- timers (một lần trước vòng đầu)
- pending callbacks, idle, prepare
- poll
- check
- close callbacks
- timers
- 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 0Phầ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 0Hai 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.
nextTickchạ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àngnextTick. Đừ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ảitimer A → timer B → promise của A. setTimeout(0)vàsetImmediate: trong callback của I/O,setImmediateluô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,setImmediatechạy trước 97–98 lần, và chính fileorder.cjscũ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 = 1js
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ờ nextTickChí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.requestvàfetchmặ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
- Code chính
- timers (một lần trước vòng đầu)
- pending callbacks, idle, prepare
- poll
- check
- close callbacks
- timers
- 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ình | Từng việc xong lúc | Tổng | setInterval chạy được |
|---|---|---|---|
| Rảnh 230 ms (mốc so sánh) | 230–231 ms | 21 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 ms | 230–238 ms | 19–21 trên 23 lần |
pbkdf2, pool 8 thread | 135–178 ms | 157–178 ms | 10–15 trên 15–17 lần |
pbkdf2Sync trên main thread | 104–108 ms mỗi việc, nối tiếp nhau | 853–867 ms | 1 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_SIZElớ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
…Syncchặ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ản | p50 /fast | p99 /fast | max /fast | Event loop delay p99 | Event loop delay max |
|---|---|---|---|---|---|
Chỉ có /fast | 1,7 ms | 3,8–3,9 ms | 5–11 ms | 2,5–2,6 ms | 2,7–9,6 ms |
Thêm 1 request /slow mỗi giây | 1,7 ms | 190–192 ms | 199–202 ms | 191 ms | 200–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
- Code chính
- timers (một lần trước vòng đầu)
- pending callbacks, idle, prepare
- poll
- check
- close callbacks
- timers
- 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.parsehoặcJSON.stringifydữ 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
- Code chính
- timers (một lần trước vòng đầu)
- pending callbacks, idle, prepare
- poll
- check
- close callbacks
- timers
- 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ớ
- 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ờ.
- Mỗi
awaittrả 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. - Sau mỗi callback, Node chạy hết
nextTickvà microtask rồi mới đi tiếp. Thứ tựnextTickvới Promise khác nhau giữa CommonJS và ES module. fs,dns.lookup, crypto vàzlibdù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.- 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ồn | Dùng cho phần |
|---|---|
| Node.js docs, The Node.js Event Loop | Sơ đồ 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 notes | libuv 1.45.0 có từ Node 20.3.0 |
| Node.js docs, Timers | Delay 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 Loop | Danh 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_SIZE | Mặc định 4, danh sách API, đặt trong code không chắc có tác dụng |
| Node.js docs, perf_hooks | monitorEventLoopDelay (nanosecond, mỗi mẫu gồm cả resolution), eventLoopUtilization và ví dụ spawnSync |
| libuv docs, Design overview | Network 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ự
nextTickvà 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.requestvàfetchmặc định dùngdns.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ộ đếmnetstat -scủ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ọilistenvớihostthì 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()) }
}