Thread pool và chặn event loop — đo, tìm, chữa

Node có hai chỗ nghẽn khác nhau mà người mới hay gộp làm một: main thread bị chặn (một callback JS chạy lâu) và thread pool bị đầy (quá nhiều việc fs/dns/crypto cùng lúc). Triệu chứng đều là "server chậm", nhưng metric khác nhau và cách chữa khác nhau. Bài này dựng lại cả hai bằng lab, đo bằng perf_hooks, tìm thủ phạm bằng CPU profile, rồi chữa bằng worker_threads và cluster.

Thuật ngữ dùng trong bài
thread pool (nhóm thread cố định của libuv, mặc định 4) · event loop delay / lag (loop trễ bao nhiêu so với lẽ ra phải chạy) · ELU — event loop utilization (tỉ lệ thời gian loop bận, không ngủ trong poll) · CPU-bound (việc tốn CPU) vs I/O-bound (việc chủ yếu chờ) · ReDoS (regex denial of service — regex backtracking theo hàm mũ làm treo CPU) · head-of-line blocking (việc đứng đầu hàng chặn mọi việc phía sau) · worker_threads (thread chạy JS riêng, V8 isolate riêng) · cluster (nhiều process Node chia nhau cổng nghe).
Kiểm chứng: Context7: Node docs v22 (dns — implementation considerations, cli — UV_THREADPOOL_SIZE, crypto), libuv docs (threadpool). Source libuv v1.51.0 src/threadpool.c. Output thật trên Node 22.23.1 (host) và node:22-bookworm-slim cho lab DNS. Máy lab 4 vCPU dùng chung, load average 7–30 trong lúc chạy: một số lần chạy bị nhiễu rõ — bài ghi cả lần nhiễu, đừng so số tuyệt đối.

1. Bài toán gốc và bối cảnh ra đời

Event loop chỉ giải được I/O nào mà kernel có API "báo khi sẵn sàng" (socket qua epoll). Nhưng có ba loại việc không có API như vậy:

libuv giải bằng một thread pool nhỏ, cố định: main thread đẩy việc vào hàng đợi, worker làm xong thì báo lại cho event loop. Còn code JS của bạn thì không được đẩy tự động đi đâu cả — nếu nó nặng, nó chặn main thread. Node 10.5 (2018) thêm worker_threads để giải phần đó.

Tối ưu cho: một lượng vừa phải việc chặn ngắn, để main thread không phải chờ. Không tối ưu cho: khối lượng lớn việc CPU (pool nhỏ, chia chung với fs/dns) và việc chặn lâu (một DNS timeout 5s chiếm một thread suốt 5s).

2. Mô hình tư duy cốt lõi

  1. Main thread là một làn đường duy nhất cho JS. Mọi việc CPU trên đó xếp hàng tuần tự; một việc 200ms = mọi request khác chờ 200ms. Đo bằng event loop delay.
  2. Thread pool là một hàng đợi FIFO dùng chung với N làn. fs, dns.lookup, crypto, zlib tranh nhau N làn đó. Pool đầy thì việc nhẹ (đọc file 1KB) phải chờ sau việc nặng (pbkdf2). Main thread vẫn rảnh — event loop delay không tăng, nên metric đó không bắt được loại nghẽn này.
Thread pool đầy, fs.readFile phải chờ sau pbkdf2 Bốn slot của thread pool đều bận chạy pbkdf2. Hàng đợi còn bốn pbkdf2 nữa và một fs.readFile ở cuối. readFile chỉ vào được slot khi đợt pbkdf2 thứ hai bắt đầu chạy xong một cái — khoảng 1 giây. Hàng đợi FIFO (main thread đẩy vào) pbkdf2 #5 #6 #7 #8 fs.readFile ⏳ file 1KB chờ ~1 giây Thread pool — UV_THREADPOOL_SIZE=4 pbkdf2 #1 pbkdf2 #2 pbkdf2 #3 pbkdf2 #4 Main thread: RẢNH — setTimeout 10ms vẫn chạy đúng giờ, event loop delay ≈ 0
Hình 1 — Nghẽn thread pool: main thread không hề bận, nên event loop delay không báo gì. Việc đọc một file nhỏ vẫn phải xếp hàng sau các job crypto. Số liệu ở mục 3.2.

3. Internals và quyết định thiết kế

3.1 Cấu trúc thread pool trong libuv 1.51

// libuv v1.51.0 — src/threadpool.c (trích)
#define MAX_THREADPOOL_SIZE 1024
static uv_thread_t default_threads[4];          // mặc định 4 thread
static unsigned int slow_io_work_running;
static struct uv__queue slow_io_pending_wq;     // hàng riêng cho việc "slow I/O"

static unsigned int slow_work_thread_threshold(void) {
  return (nthreads + 1) / 2;                    // slow I/O dùng tối đa một nửa pool
}

3.2 Lab: pbkdf2 làm fs.readFile chờ 1 giây

// 30-pool.js — 8 job pbkdf2 cùng lúc, rồi đọc 1 file nhỏ, rồi 1 timer 10ms
for (let i = 1; i <= 8; i++)
  crypto.pbkdf2('pw', 'salt', 300_000, 64, 'sha512', () => console.log(`${at()}ms  pbkdf2 #${i} xong`));
fs.readFile(__filename, () => console.log(`${at()}ms  fs.readFile xong  <-- file nhỏ, lẽ ra ~1ms`));
setTimeout(() => console.log(`${at()}ms  setTimeout 10ms (main thread vẫn rảnh)`), 10);
$ node 30-pool.js                          # UV_THREADPOOL_SIZE mặc định = 4
    8ms  setTimeout 10ms (main thread vẫn rảnh)
  597ms  pbkdf2 #4 xong
  638ms  pbkdf2 #3 xong
  785ms  pbkdf2 #5 xong
  979ms  pbkdf2 #2 xong
 1006ms  pbkdf2 #7 xong
 1007ms  fs.readFile xong  <-- file nhỏ, lẽ ra ~1ms
 1028ms  pbkdf2 #6 xong
 1097ms  pbkdf2 #1 xong
 2417ms  pbkdf2 #8 xong

$ UV_THREADPOOL_SIZE=8 node 30-pool.js
   74ms  setTimeout 10ms (main thread vẫn rảnh)
  462ms  pbkdf2 #3 xong
  465ms  fs.readFile xong  <-- file nhỏ, lẽ ra ~1ms
  531ms  pbkdf2 #6 xong
  ...
  672ms  pbkdf2 #5 xong

Đọc kết quả: timer 10ms chạy đúng giờ (main thread rảnh), nhưng đọc file 1KB mất ~1 giây với pool 4. Tăng pool lên 8 giúp readFile nhanh hơn một nửa (465ms) — nhưng readFile vẫn chờ, vì 8 job pbkdf2 chiếm hết 8 thread trước nó. Và máy chỉ có 4 core: 8 thread CPU không làm pbkdf2 nhanh hơn, chỉ chia nhỏ CPU ra. (Lần chạy pool 4 khác bị nhiễu nặng: timer 10ms chạy ở 576ms, pbkdf2 cuối ở 3394ms — máy lab dùng chung.)

3.3 Lab: DNS chậm — và giới hạn slow I/O cứu fs

Dựng container tạm với DNS server không tồn tại (--dns 10.255.255.1, timeout 3s, 1 lần thử):

// 34-dns-starve.js
for (let i = 1; i <= 4; i++)
  dns.lookup(`api-${i}.partner.example`, (err) => console.log(`${at()}ms  dns.lookup #${i}: ${err?.code}`));
fs.readFile('/etc/hostname', () => console.log(`${at()}ms  fs.readFile /etc/hostname xong`));
dns.resolve4('api-5.partner.example', (err) => console.log(`${at()}ms  dns.resolve4 (c-ares, không dùng pool): ${err?.code}`));
$ docker run --rm --name tmp-node-dns --dns 10.255.255.1 --dns-option timeout:3 --dns-option attempts:1 \
    -v $PWD:/lab -w /lab node:22-bookworm-slim node 34-dns-starve.js
   10ms  fs.readFile /etc/hostname xong           ← fs KHÔNG bị chặn
 6014ms  dns.lookup #1: EAI_AGAIN
 6014ms  dns.lookup #2: EAI_AGAIN                 ← chỉ 2 lookup chạy cùng lúc
12060ms  dns.lookup #4: EAI_AGAIN
12060ms  dns.lookup #3: EAI_AGAIN                 ← lookup #3, #4 phải chờ đợt đầu: 12 giây
43579ms  dns.resolve4 (c-ares, không dùng pool): ETIMEOUT

Nhiều bài viết nói "4 DNS lookup chậm làm treo cả fs". Với libuv hiện tại không đúng: DNS chỉ được dùng 2/4 thread, fs vẫn còn 2 thread và xong trong 10ms. Nhưng failure mode thật vẫn còn, chỉ đổi chỗ: các DNS lookup xếp hàng sau nhau. Service gọi 50 API bên ngoài khi DNS chậm → lookup thứ 50 chờ 25 lượt × 6s. Và mỗi fetch()/http.request() tới hostname đều đi qua dns.lookup mặc định. (Mỗi lookup mất 6s chứ không phải 3s vì glibc hỏi cả A và AAAA — suy luận, chưa verify.)

3.4 Main thread bị chặn: JSON.stringify làm server đứng 1 giây

// 31-block-server.js — /fast trả ngay; /report JSON.stringify 300k object trên main thread
if (req.url === '/report') {
  const s = performance.now(); const len = JSON.stringify(big).length;
  return res.end(`stringify ${len} bytes mất ${(performance.now() - s).toFixed(0)}ms`);
}
// server tự đo bằng perf_hooks:
const h = monitorEventLoopDelay({ resolution: 10 }); h.enable();
const u = performance.eventLoopUtilization(elu);
$ node 31-probe.mjs      # gọi /fast lúc rảnh, rồi 5 /report cùng lúc + 1 /fast chen giữa
/fast lúc rảnh: 5ms   | ELU=0.15 lag p99=0ms max=0ms
/fast khi có 5 /report: 874ms | report: 164ms, 149ms, 193ms, 134ms, 228ms
metrics sau đó: ELU=0.97 lag p99=707ms max=707ms

Mỗi JSON.stringify chỉ 130–230ms — không ai nghĩ là "nặng". Nhưng 5 cái chạy tuần tự trên main thread làm request /fast (lẽ ra 5ms) phải chờ 874ms. Lần chạy khác: 1334ms. Cả ELU (0.97) lẫn event loop delay (707ms) đều bắt được — trái ngược với nghẽn thread pool ở 3.2.

Request nhanh bị kẹt sau request nặng trên main thread, và khi đưa việc nặng sang worker Hàng trên: main thread chạy 5 lần JSON.stringify liền nhau, request /fast phải chờ tới khi xong mới đi qua. Hàng dưới: việc nặng chạy trên worker thread, request /fast đi qua main thread ngay. Không có worker main stringify ×5 (≈870ms) /fast 874ms Có worker_threads worker main /fast 12ms
Hình 2 — Head-of-line blocking trên main thread. Đưa việc nặng sang worker thì main thread chỉ còn nhận và trả message; /fast đi qua ngay (12ms thay vì 874ms — số đo lab 4 bên dưới).

3.5 worker_threads: cách chữa và cái giá của nó

// 31b-worker-server.js — /report chạy trong 1 worker, dữ liệu nằm sẵn trong worker
const worker = new Worker(__filename); const waiting = new Map(); let seq = 0;
worker.on('message', ({ id, text }) => { waiting.get(id)(text); waiting.delete(id); });
if (req.url === '/report') {
  const id = ++seq;
  return res.end(await new Promise((r) => { waiting.set(id, r); worker.postMessage(id); }));
}
$ node 31-probe.mjs      # cùng probe như 3.4
/fast lúc rảnh: 6ms   | ELU=0.38 lag p99=0ms max=0ms
/fast khi có 5 /report: 12ms | report: 192ms, 780ms, 147ms, 124ms, 157ms
metrics sau đó: ELU=0.05 lag p99=114ms max=127ms

/fast từ 874ms xuống 12ms, ELU của main từ 0.97 xuống 0.05. Nhưng đọc kỹ các cái giá:

3.6 cluster: nhân process để dùng nhiều core

// 35-cluster.js — cùng handler /cpu 100ms, N process chia cổng 3100
if (cluster.isPrimary) { for (let i = 0; i < N; i++) cluster.fork(); }
else http.createServer((req, res) => { busy(100); res.end(`pid ${process.pid}`); }).listen(3100, '127.0.0.1');
== 1 worker
/cpu x20: tổng 2328ms | p50 1106ms | p99 2075ms
/cpu x20: tổng 3066ms | p50 1966ms | p99 2877ms
== 3 worker
/cpu x20: tổng 2400ms | p50 524ms | p99 825ms       ← lần đầu: tổng bị nhiễu, nhưng p50/p99 đã giảm
/cpu x20: tổng 793ms | p50 423ms | p99 722ms        ← ~3 lần nhanh hơn

cluster không làm một request nhanh hơn — nó cho N request CPU chạy song song trên N core. Trên Kubernetes, cách tương đương và phổ biến hơn là nhiều pod, mỗi pod 1 process, CPU limit ~1 core.

4. Đảm bảo và giới hạn

ĐiềuGiá trị / hành vi
Kích thước poolMặc định 4, tối đa 1024, cố định sau khi khởi động
Thứ tự trong poolFIFO, không ưu tiên (trừ hàng slow I/O riêng)
DNS getaddrinfoTối đa (n+1)/2 thread cùng lúc
Throughput JS của 1 process≈ 1 / (CPU JS mỗi request) — định luật Amdahl: phần tuần tự trên main thread là trần
Pool lớn hơn số coreChỉ có lợi cho việc chờ (fs trên đĩa mạng chậm, DNS); việc CPU không nhanh hơn
worker_threadsSong song thật, nhưng không chia sẻ heap (trừ SharedArrayBuffer)

5. Failure modes

5.1 ReDoS — một chuỗi 27 ký tự treo process 1 giây

// 32-redos.js
const re = /^(a+)+$/;
re.test('aaaa!');                                     // warm-up: cho V8 compile regex trước
for (const n of [20, 22, 24, 26]) {
  const s = 'a'.repeat(n) + '!';
  const t = performance.now(); re.test(s);
  console.log(`input ${n + 1} ký tự: ${(performance.now() - t).toFixed(0)}ms`);
}
$ node 32-redos.js
input 21 ký tự: 12ms
input 23 ký tự: 41ms
input 25 ký tự: 200ms
input 27 ký tự: 1101ms           ← thêm 2 ký tự ≈ ×4–5 thời gian

Regex chạy trên main thread, không cắt ngang được. Một request có input xấu (cố ý hay vô tình) treo cả process → chậm toàn bộ, không mất dữ liệu. Lần chạy đầu không warm-up cho số lộn xộn (n=20 mất 2327ms, lâu hơn n=24) vì V8 chạy regex bằng bytecode trước rồi mới compile ra mã máy — lại một lý do phải đo nhiều lần. Chữa: giới hạn độ dài input trước khi test regex; tránh nhóm lặp lồng nhau ((a+)+, (\w+\s?)*); dùng thư viện re2 (không backtracking) cho regex nhận input người dùng (chưa verify).

5.2 Nghẽn thread pool không hiện trên event loop delay

Lab 3.2: main thread rảnh, lag ≈ 0, nhưng fs chậm 1 giây. Nếu dashboard chỉ có event loop delay, sự cố này "vô hình". Dấu hiệu: latency của thao tác fs/dns/crypto tăng trong khi ELU thấp. Cần đo latency của chính các thao tác đó (bọc timer quanh fs.promises.*, dns.lookup) — Node không có metric độ dài hàng đợi thread pool sẵn.

5.3 Hàm *Sync trong request

crypto.pbkdf2Sync, fs.readFileSync, zlib.gzipSync, child_process.execSync chạy trên main thread — tất cả biến việc của thread pool thành việc chặn loop. Phổ biến nhất: hash password bằng bcrypt/pbkdf2Sync trong endpoint login → login đông người là cả API chậm.

5.4 Tải tăng đột biến trên process đã bão hoà

ELU chạm 1.0: mọi request chậm đều nhau, healthcheck trễ, orchestrator restart pod giữa lúc tải cao → tải dồn sang pod còn lại → sụp dây chuyền. Chống: bắn 503 sớm khi event loop delay vượt ngưỡng (kỹ thuật "load shedding" — từ chối bớt tải), readiness probe phản ánh quá tải, liveness probe để timeout rộng.

6. Vận hành trong production

MetricBắt đượcKhông bắt được
Event loop delay p99/maxCallback chặn main thread, GC dàiNghẽn thread pool
ELUMain thread gần bão hoà (> 0.8)Nghẽn thread pool, chờ DB
Latency theo loại thao tác (fs, dns, crypto)Nghẽn thread pool—
CPU% của processTổng tảiKhông phân biệt main thread với pool (CPU 150% nghĩa là pool đang làm việc)

Tìm thủ phạm bằng CPU profile — lab: service có hàm validate tên bằng regex và hàm tính tổng giỏ hàng, đoán xem cái nào ăn CPU:

$ node --cpu-prof --cpu-prof-dir=prof2 36-hot.js
$ node 36-top.js prof2/*.cpuprofile        # cộng self-time theo hàm, in top 3
   376ms  40.7%  RegExp: ^([a-zA-Z0-9]+\s?)*$ :0
   200ms  21.6%  validateName 36-hot.js:2
   182ms  19.7%  (garbage collector) :0

Profile chỉ thẳng vào regex, không phải hàm reduce trên 100k phần tử mà ai cũng nghi. Trong production: mở inspector bằng kill -USR1 <pid> (chỉ bind 127.0.0.1) rồi profile từ Chrome DevTools, hoặc chạy --cpu-prof trên một instance canary. (clinic doctor/0x là công cụ hay dùng — chưa verify trong lab.)

Cấu hình:

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

CáchSong song thậtChia sẻ bộ nhớChi phíCô lập lỗiDùng khi
Chia nhỏ + setImmediateKhôngCóRất thấp—Việc dài vừa phải, cần giữ loop thở
worker_threadsCóChỉ SharedArrayBufferTB (isolate/worker)Worker lỗi không giết mainViệc CPU trong request: resize ảnh, parse lớn, hash
cluster / nhiều podCóKhôngCao (process đầy đủ)TốtScale toàn bộ service theo core
child_processCóKhôngCaoTốt nhấtChạy tool ngoài (ffmpeg), code không tin cậy
Queue + service riêngCóKhôngCao (hạ tầng)Tốt nhấtViệc nặng không cần trả kết quả ngay (báo cáo, export)

Dùng worker_threads khi việc CPU phải nằm trong đường request và ngắn (dưới vài trăm ms). Tránh worker_threads khi việc có thể chạy nền — đưa vào queue sẽ đơn giản và bền hơ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. Tăng UV_THREADPOOL_SIZE lên 128 "cho chắc" — việc CPU không nhanh hơn, chỉ thêm thread tranh CPU với main thread.
  2. Tạo new Worker() mỗi request — tốn thời gian khởi động isolate; dùng pool.
  3. Gửi object khổng lồ qua postMessage — chi phí clone chạy trên main thread, mất lợi ích worker.
  4. Chỉ theo dõi CPU% và latency tổng — không phân biệt được main thread bị chặn với pool nghẽn.
  5. Regex tự viết cho input người dùng không giới hạn độ dài (5.1).
  6. bcrypt/pbkdf2 đồng bộ trong login (5.3).

Postmortem công khai: Cloudflare 2019-07-02 — một regex backtracking đưa CPU lên 100% trên toàn bộ edge (blog.cloudflare.com, "Details of the Cloudflare outage on July 2, 2019"). Stack Overflow 2016-07-20 — một regex cắt khoảng trắng gặp chuỗi 20.000 khoảng trắng làm trang chủ sập 34 phút (stackstatus.net, postmortem 20/07/2016). Cả hai không phải Node, nhưng trên Node hậu quả còn nặng hơn vì chỉ có một thread chạy JS.

10. Câu hỏi phụ interviewer hay hỏi tiếp

Những gì chạy trong thread pool?
fs (trừ watch), dns.lookup, crypto async (pbkdf2, scrypt, randomBytes, randomFill, generateKeyPair), zlib async. Socket/HTTP thì không.
Tăng UV_THREADPOOL_SIZE có luôn tốt không?
Chỉ tốt khi việc trong pool là chờ. Việc CPU thì trần là số core; thêm thread chỉ tranh CPU với main thread.
DNS chậm có làm treo fs không?
Không hoàn toàn: libuv cho getaddrinfo tối đa (n+1)/2 thread. Nhưng các lookup xếp hàng sau nhau — lab: lookup #3 chờ 12s.
worker_threads khác cluster thế nào?
worker: thread trong cùng process, isolate riêng, giao tiếp bằng message, dùng cho một việc CPU. cluster: nhiều process, mỗi process một event loop đầy đủ, dùng để scale cả server.
Làm sao biết event loop bị chặn và bởi cái gì?
monitorEventLoopDelay + ELU để biết có; CPU profile (--cpu-prof / inspector) để biết cái gì.
setImmediate có làm việc CPU chạy song song không?
Không. Chỉ chia nhỏ để request khác chen vào giữa; tổng thời gian CPU không đổi.

Phụ lục: lab đã chạy

Môi trường: Node v22.23.1 (libuv 1.51.0) trên Linux 6.8, 4 vCPU dùng chung (load 7–30). Lab DNS dùng container tạm node:22-bookworm-slim (Node 22.23.3). Code chính ở các mục trên; phần còn lại:

// 31-probe.mjs
const get = async (p) => { const s = performance.now(); const t = await (await fetch(`http://127.0.0.1:3101${p}`)).text(); return [performance.now() - s, t]; };
await get('/fast'); await get('/metrics');                       // warm-up + reset số đo
const [base] = await get('/fast');
console.log(`/fast lúc rảnh: ${base.toFixed(0)}ms   | ${(await get('/metrics'))[1]}`);
const reports = Array.from({ length: 5 }, () => get('/report'));
await new Promise((r) => setTimeout(r, 5));
const [busy] = await get('/fast');
const rs = await Promise.all(reports);
console.log(`/fast khi có 5 /report: ${busy.toFixed(0)}ms | report: ${rs.map(([, t]) => t.split(' mất ')[1]).join(', ')}`);
console.log(`metrics sau đó: ${(await get('/metrics'))[1]}`);

// 36-top.js — đọc .cpuprofile, cộng self-time theo hàm
const p = JSON.parse(require('fs').readFileSync(process.argv[2]));
const dt = {}; p.samples.forEach((id, i) => { dt[id] = (dt[id] || 0) + (p.timeDeltas[i] || 0); });
const byFn = {};
for (const n of p.nodes) { const k = `${n.callFrame.functionName || '(anonymous)'} ${n.callFrame.url.split('/').pop()}:${n.callFrame.lineNumber + 1}`; byFn[k] = (byFn[k] || 0) + (dt[n.id] || 0); }
const total = Object.values(byFn).reduce((a, b) => a + b, 0);
Object.entries(byFn).sort((a, b) => b[1] - a[1]).slice(0, 3)
  .forEach(([k, v]) => console.log(`${(v / 1000).toFixed(0).padStart(6)}ms ${(100 * v / total).toFixed(1).padStart(5)}%  ${k}`));

// 36-hot.js
const validateName = (s) => /^([a-zA-Z0-9]+\s?)*$/.test(s);
const sumPrices = (items) => items.reduce((a, b) => a + b.price, 0);
const items = Array.from({ length: 100_000 }, (_, i) => ({ price: i }));
for (let i = 0; i < 20; i++) { sumPrices(items); validateName('a'.repeat(20) + '!'); }
node 30-pool.js; UV_THREADPOOL_SIZE=8 node 30-pool.js
node 31-block-server.js &  node 31-probe.mjs          # rồi làm lại với 31b-worker-server.js
WORKERS=1 node 35-cluster.js &  node 22-client.mjs /cpu 20   # rồi WORKERS=3
curl -sL https://raw.githubusercontent.com/libuv/libuv/v1.51.0/src/threadpool.c | grep -n "slow_io\|nthreads + 1"

Chưa verify: vì sao mỗi lookup mất 6s thay vì 3s (nghi A + AAAA); piscina, re2, clinic; hành vi thread pool khi bật UV_USE_IO_URING=1.