Node.js chạy thế nào — V8, libuv, một thread JS và nhiều thread OS
Bài mở đầu cho series. Mục tiêu: trả lời chắc chắn câu "Node đơn luồng hay đa luồng?" bằng bằng
chứng thật (đếm thread, strace), hiểu request đi qua những tầng nào, và biết cái giá của mô hình
này trong production — từ race condition qua await tới việc Node làm PID 1 trong container.
Hai bài kế tiếp đi sâu vào 6 phase của event loop và
microtask.
Thuật ngữ dùng trong bài
runtime (môi trường chạy code JS ngoài browser) · V8 (engine JS của Chrome: parse, JIT compile, GC) · JIT (just-in-time — biên dịch JS ra mã máy lúc đang chạy) · libuv (thư viện C lo event loop, I/O bất đồng bộ, thread pool) · binding (lớp C++ nối API JS của Node với libuv/OpenSSL/…) · non-blocking I/O (gọi I/O xong trả về ngay, kết quả báo sau) · epoll (API Linux theo dõi hàng nghìn socket bằng một syscall) · thread pool (nhóm thread cố định của libuv cho việc không có API bất đồng bộ tốt: file, DNS, crypto) · concurrency (nhiều việc đang dở cùng lúc) vs parallelism (nhiều việc chạy cùng một lúc trên nhiều core).
Kiểm chứng: tài liệu qua Context7 (Node.js docs v22: dns, cli, http; libuv docs: design, threadpool). Source trích từ libuv v1.51.0 (src/unix/linux.c,src/threadpool.c). Output là output thật trên Node 22.23.1 (host) và imagenode:22-bookworm-slim(Node 22.23.3) cho lab container. Máy 4 vCPU dùng chung, load dao động — số ms chỉ để xem tỉ lệ.
1. Bài toán gốc và bối cảnh ra đời
Năm 2009 có hai thứ gặp nhau:
- Bài toán: server kiểu một-thread-một-kết-nối không chịu nổi hàng chục nghìn kết nối đồng thời (C10K) — mỗi thread tốn MB stack, kernel mất công chuyển qua lại giữa các thread, trong khi hầu hết thời gian các thread chỉ ngồi chờ DB và mạng. nginx đã chứng minh event loop + epoll giải được, nhưng viết ứng dụng bằng C theo kiểu callback thì quá khổ.
- Công cụ: V8 (ra cùng Chrome năm 2008) biến JS thành ngôn ngữ nhanh nhờ JIT. JS lại có sẵn văn hoá callback từ browser và chưa có API I/O chặn nào — không có thư viện cũ nào "lỡ" chặn luồng.
Ryan Dahl ghép V8 + một event loop C (ban đầu libev/libeio, từ 2011 là libuv — viết lại để chạy được cả Windows IOCP) thành Node.js. (Lịch sử theo trang About của nodejs.org và lịch sử libuv; không có lab.)
Tối ưu cho: nhiều kết nối đồng thời, mỗi kết nối chủ yếu chờ I/O; một ngôn ngữ cho cả frontend và backend. Không tối ưu cho: tính toán nặng trong request, việc cần nhiều core cho một request, và hệ thống cần cô lập lỗi giữa các request (một process chết là mọi request trong đó chết theo).
2. Mô hình tư duy cốt lõi
Node = V8 chạy JS trên một thread + libuv giữ event loop; JS chỉ ra lệnh I/O, việc chờ do kernel (socket) hoặc thread pool (file, DNS, crypto) làm. Main thread không bao giờ ngồi chờ I/O — nó chỉ chạy callback khi kết quả đã sẵn sàng.
3. Internals và quyết định thiết kế
3.1 Các thành phần trong một binary node
$ node -p "process.versions" # rút gọn
"node": "22.23.1",
"v8": "12.4.254.21-node.56", ← engine JS
"uv": "1.51.0", ← event loop, I/O, thread pool
"llhttp": "9.4.2", ← parser HTTP/1.1 (viết bằng TypeScript sinh ra C)
"ares": "1.34.6", ← c-ares: DNS bất đồng bộ cho dns.resolve*()
"openssl": "3.5.7", ← TLS, crypto
"nghttp2": "1.69.0", ← HTTP/2
"undici": "6.27.0", ← client HTTP của fetch()
"zlib": "1.3.1-e00f703", "simdjson": "4.5.0", "sqlite": "3.51.3", ...
Tầng JS (lib/*.js: http, fs, net) gọi xuống binding C++ (src/*.cc), binding gọi libuv
hoặc thư viện C tương ứng. Quyết định thiết kế: Node giữ lõi nhỏ và đẩy phần lớn chức năng ra
npm. Được: lõi ổn định, hệ sinh thái lớn nhanh. Mất: chất lượng/độ an toàn phụ thuộc hàng trăm package bên thứ ba (mục 8).
3.2 Một process Node có bao nhiêu thread?
// 20-threads.js — đọc tên thread từ /proc/self/task/*/comm
console.log('lúc khởi động :', count(threads()));
fs.readFile(__filename, () => console.log('sau 1 fs.readFile:', count(threads())));
$ node 20-threads.js
lúc khởi động : node×6, DelayedTaskSche×1
sau 1 fs.readFile: node×6, DelayedTaskSche×1, libuv-worker×4
$ UV_THREADPOOL_SIZE=8 node 20-threads.js | tail -1
sau 1 fs.readFile: node×6, DelayedTaskSche×1, libuv-worker×8
$ node --v8-pool-size=1 20-threads.js | head -1
lúc khởi động : node×3, DelayedTaskSche×1
$ node --v8-pool-size=8 20-threads.js | head -1
lúc khởi động : node×10, DelayedTaskSche×1
Đọc kết quả:
- node×6 = main thread + 4 thread nền của V8 (đổi theo
--v8-pool-size: 1 → 3, 8 → 10) + 1 thread khác của Node (có lẽ thread xử lý tín hiệu cho inspector — chưa verify). Thread nền V8 lo GC song song và compile nền. - DelayedTaskScheduler — thread hẹn giờ cho task nền của V8.
- libuv-worker×4 — thread pool, chỉ được tạo khi lần đầu cần (lazy), số lượng theo
UV_THREADPOOL_SIZE.
Câu trả lời đúng cho "Node đơn luồng?": JS của bạn chạy trên một thread; process Node có ~11 thread. Lab có 7 thread lúc khởi động, 11 sau lần đọc file đầu tiên.
3.3 Socket đi epoll, file đi thread pool — chứng minh bằng strace
// 21-who-does-io.js
console.error('main thread pid =', process.pid);
const srv = net.createServer((s) => s.end('x')).listen(0, '127.0.0.1', () => {
net.connect(srv.address().port, '127.0.0.1').on('data', () => {
fs.readFile('/etc/hostname', () => srv.close());
});
});
$ strace -f -e trace=openat,read,connect,accept4 node 21-who-does-io.js 2>&1 | grep -E "main thread|hostname|connect\(|accept4|\"x\""
main thread pid = 200499
[pid 200499] connect(22, {... sin_addr=inet_addr("127.0.0.1")}, 16) = -1 EINPROGRESS ← main thread, non-blocking
[pid 200499] accept4(21, NULL, NULL, SOCK_CLOEXEC|SOCK_NONBLOCK) = 23 ← main thread
[pid 200499] read(22, "x", 65536) = 1 ← main thread
[pid 200509] openat(AT_FDCWD, "/etc/hostname", O_RDONLY|O_CLOEXEC) = 24 ← THREAD KHÁC: libuv-worker
| Loại việc | Chạy ở đâu | Vì sao |
|---|---|---|
| TCP, UDP, HTTP, pipe, TTY | Main thread + epoll | Kernel có API báo "socket sẵn sàng" → không cần thread chờ. |
fs.* (trừ fs.watch) | Thread pool | epoll không nhận file thường (EPERM) → phải gọi read() chặn trong thread riêng. |
dns.lookup (mặc định của http, net) | Thread pool | Gọi getaddrinfo() của libc — hàm chặn. |
dns.resolve* | Main thread (c-ares + epoll) | Tự gửi gói DNS qua socket; nhưng không đọc /etc/hosts. |
crypto.pbkdf2/scrypt/randomBytes (async), zlib async | Thread pool | Việc CPU — đẩy ra khỏi main thread. |
| Code JS của bạn | Main thread | Luôn luôn — trừ khi dùng worker_threads. |
io_uring thì sao? libuv ≥ 1.45 biết dùng io_uring (API I/O bất đồng bộ mới của Linux). Trong
libuv 1.51, io_uring cho thao tác file chỉ bật khi đặt UV_USE_IO_URING=1; mặc định
libuv chỉ dùng io_uring để gom lệnh epoll_ctl (source uv__use_io_uring() trong
linux.c: "SQPOLL is all kinds of buggy but epoll batching should work fine"). Vì vậy trong strace
có io_uring_setup nhưng openat vẫn chạy trên libuv-worker.
3.4 Concurrency rẻ, nhưng chỉ khi việc là chờ I/O
// 22-server.js
http.createServer(async (req, res) => {
if (req.url === '/io') await sleep(100); // chờ I/O 100ms (giả lập query DB)
if (req.url === '/cpu') busy(100); // tính toán 100ms trên main thread
res.end('ok');
}).listen(3100, '127.0.0.1');
$ node 22-client.mjs /io 100 # 100 request đồng thời, 3 lần
/io x100: tổng 371ms | p50 230ms | p99 297ms
/io x100: tổng 763ms | p50 632ms | p99 647ms
/io x100: tổng 278ms | p50 176ms | p99 181ms
$ node 22-client.mjs /cpu 20 # chỉ 20 request
/cpu x20: tổng 2165ms | p50 1066ms | p99 1971ms
/cpu x20: tổng 2090ms | p50 1024ms | p99 1936ms
100 request chờ I/O xong trong ~0,3s (lý tưởng 0,1s; phần dư là chi phí mở 100 kết nối trên máy đang tải cao). 20 request CPU mất ~2,1s = 20 × 100ms — chạy tuần tự hoàn toàn, p99 gần bằng tổng thời gian.
3.5 Mỗi kết nối rảnh tốn bao nhiêu RAM?
$ ulimit -n 20000; node --expose-gc 23-conn-mem.js
kết nối đang mở: 5000 | RSS server: 45MB -> 60MB | ~3.1KB/kết nối | số thread: 7
5000 kết nối TCP đang mở, số thread không đổi (7), RAM thêm ~15MB. So với mô hình một-thread-một-kết-nối: 5000 thread × stack mặc định 8MB (bộ nhớ ảo; phần thật sự dùng ít hơn) — chênh nhau nhiều bậc. Đây chính là lời giải C10K.
4. Đảm bảo và giới hạn
- Không có data race trên object JS: chỉ một thread chạy JS nên không bao giờ có hai dòng code cùng ghi một biến cùng lúc. Không cần mutex cho biến trong bộ nhớ.
- Nhưng VẪN có race condition logic qua
await: giữa hai dòng cóawaitở giữa, request khác có thể chạy và sửa dữ liệu (lab 5.1). - Một process dùng tối đa ~1 core cho JS. Thread pool và thread V8 dùng thêm core, nhưng code của bạn thì không. Throughput trần ≈ 1 / (thời gian CPU JS mỗi request).
- Giới hạn tài nguyên theo process: số file descriptor (
ulimit -n) chặn số kết nối; heap V8 có trần (bài 6). - Không cô lập lỗi: một exception không bắt làm chết process → mọi request đang xử lý trong process đó mất theo. PHP-FPM ngược lại: một worker chết chỉ mất một request.
5. Failure modes
5.1 Race condition qua await — lost update và lộ dữ liệu giữa user
// 24-race-await.js
let currentUser = null; // biến module-level dùng chung mọi request
const db = { balance: 100 };
async function handle(user, amount) {
currentUser = user; // "set context" kiểu PHP
const bal = db.balance; // đọc
await sleep(10); // chờ I/O (vd gọi payment gateway)
db.balance = bal - amount; // ghi dựa trên giá trị cũ -> lost update
return `${user} rút ${amount}, log ghi user=${currentUser}`;
}
Promise.all([handle('alice', 30), handle('bob', 50)])
$ node 24-race-await.js
alice rút 30, log ghi user=bob ← lộ ngữ cảnh: request của alice nhìn thấy bob
bob rút 50, log ghi user=bob
balance cuối = 50 (đúng phải là 20) ← lost update: mất giao dịch của alice
Dev từ PHP rất hay dính lỗi này: trong PHP-FPM, biến global chỉ sống trong một request nên an toàn; trong Node,
module chỉ nạp một lần và mọi request dùng chung. Chữa: ngữ cảnh request đi qua
tham số hoặc AsyncLocalStorage; cập nhật số dư bằng câu SQL nguyên tử
(UPDATE … SET balance = balance - $1 WHERE balance >= $1) hoặc khoá ở DB — không đọc-sửa-ghi trong JS.
5.2 Node là PID 1 trong container: không chịu tắt
$ for mode in pid1 init handler; do docker run -d ... ; docker stop -t 5 tmp-node-sig; done
pid1: docker stop -t 5 mất 12.3s | exit=137 | log: ← bị SIGKILL sau 5s chờ
init: docker stop -t 5 mất 6.22s | exit=143 | log: ← --init (tini) chuyển SIGTERM, Node chết theo mặc định
handler: docker stop -t 5 mất 4.60s | exit=0 | log: SIGTERM: dọn dẹp rồi thoát ← tự bắt SIGTERM
(Docker trên máy lab tốn ~4–5s chi phí riêng cho mỗi lần stop vì máy đang tải cao — nhìn exit code là rõ nhất.)
Linux không áp hành động mặc định "chết khi nhận SIGTERM" cho PID 1 nếu process không tự đăng ký handler.
Node không đăng ký sẵn → SIGTERM bị bỏ qua → orchestrator chờ hết grace period rồi SIGKILL (exit 137) → request
đang xử lý bị cắt ngang, kết nối DB không đóng sạch. Chữa: luôn có
process.on('SIGTERM', gracefulShutdown); thêm --init / tini để dọn process con zombie.
Chạy qua npm start còn tệ hơn: npm thành PID 1 và (tuỳ phiên bản) không chuyển tín hiệu cho node — chưa verify trong lab.
5.3 Một exception giết mọi request đang chạy
Xem lab unhandled rejection ở bài 3 mục 5.2: process chết với exit code 1, mọi kết nối đang mở bị đứt. Hệ quả: chậm (client phải retry) và có thể trùng nếu client retry một thao tác không idempotent (gọi lại nhiều lần vẫn cho cùng kết quả) đã chạy được một nửa.
5.4 Tải tăng đột biến
Kết nối mới vẫn được nhận (rẻ, 3KB/kết nối), nhưng nếu mỗi request tốn CPU thì tất cả chậm đều nhau — không
có cơ chế từ chối sớm. Thêm giới hạn ở tầng trước (load balancer, server.maxConnections, rate limit) và
theo dõi event loop delay (bài 4).
6. Vận hành trong production
| Tham số / việc | Mặc định | Khi nào đổi |
|---|---|---|
| Số process | 1 | Luôn chạy N process = số core (cluster, PM2) hoặc N pod × 1 process với CPU limit ~1 core. |
UV_THREADPOOL_SIZE | 4 | Nhiều fs/crypto/dns.lookup đồng thời — bài 4. Phải đặt bằng biến môi trường trước khi process chạy. |
--max-old-space-size | Theo RAM / cgroup | Chạy trong container — bài 6. |
| Xử lý SIGTERM | Không có | Luôn tự viết graceful shutdown (5.2). |
ulimit -n | Thường 1024 | Server nhiều kết nối — nâng lên hàng chục nghìn. |
| Phiên bản | — | Chỉ dùng bản LTS (số chẵn). Lịch nâng cấp theo vòng đời LTS của Node. |
Metric tối thiểu: event loop delay, ELU, heap used/limit, số restart, số active handles. Người trực cần biết đọc CPU profile và heap snapshot — hai kỹ năng này khác với debug PHP (thường chỉ cần log + APM).
7. So sánh và ma trận quyết định
| Trục | Node.js | PHP-FPM (Laravel) | Go | Java/Spring | Bun / Deno |
|---|---|---|---|---|---|
| Hiệu năng I/O đồng thời | Cao | Thấp–TB (giới hạn bởi số worker) | Cao | Cao (virtual thread) | Cao |
| Hiệu năng CPU | TB (1 core/process) | TB | Cao | Cao | TB |
| Cô lập lỗi | Thấp (process chung) | Cao (process/request) | TB (panic giết process) | TB | Thấp |
| Độ phức tạp vận hành | TB | Thấp | Thấp (1 binary) | Cao (JVM tuning) | TB |
| Hệ sinh thái | Rất lớn (npm) | Lớn | TB | Rất lớn | Tương thích npm phần lớn (chưa verify) |
| Độ trưởng thành | Cao (từ 2009) | Cao | Cao | Rất cao | Thấp hơn |
| Tuyển người | Rất dễ | Dễ | TB | Dễ | Khó |
Dùng Node khi: service I/O-bound, realtime, BFF/API gateway, team TypeScript full-stack. Tránh khi: xử lý CPU nặng là phần chính, cần cô lập lỗi mạnh giữa request, hoặc team không có người hiểu event loop để review.
8. Góc nhìn tech lead: tác động tổ chức
- Một ngôn ngữ cho FE + BE giúp chia sẻ type/validation, dev chuyển qua lại dễ — nhưng đừng để điều này thành lý do dùng Node cho service CPU-bound.
- Rủi ro chuỗi cung ứng npm là rủi ro tổ chức, không phải kỹ thuật: một project Express nhỏ kéo
theo hàng trăm package. Vụ
event-stream(2018, package bị chèn mã đánh cắp ví bitcoin) vàleft-pad(2016) là ví dụ công khai. Cần: lockfile bắt buộc,npm audit/Renovate ở CI, review khi thêm dependency mới, cân nhắc registry nội bộ. - Nhịp nâng cấp: mỗi bản LTS có hạn hỗ trợ; lên kế hoạch nâng cấp định kỳ thay vì để 3 năm rồi nhảy nhiều bản một lúc.
- Onboarding: dev từ PHP cần học lại 3 điều: state module dùng chung giữa request (5.1), không được chặn main thread, và crash là mất mọi request.
- Exit strategy: giữ ranh giới service bằng HTTP/gRPC + contract (OpenAPI/proto), không chia sẻ code runtime-specific qua ranh giới. Khi một service cần CPU, viết lại riêng service đó bằng Go/Rust mà không đụng phần còn lại.
9. Anti-patterns và bài học thực tế
- Biến module-level chứa ngữ cảnh request (
currentUser,currentTenant) — lộ dữ liệu giữa user (5.1). - Đọc-sửa-ghi qua
await— lost update (5.1). - Chạy 1 process Node trên máy 8 core và thắc mắc sao CPU chỉ 12%.
- Container không xử lý SIGTERM, chạy qua
npm start(5.2). - Dùng API
*Sync(readFileSync,execSync) trong code chạy mỗi request — chặn cả process. - Nghĩ
async= chạy song song — chỉ là concurrency trên một thread. - Thêm dependency cho việc vài dòng code — tăng bề mặt tấn công supply chain.
10. Câu hỏi phụ interviewer hay hỏi tiếp
- Node đơn luồng hay đa luồng?
- JS chạy trên một thread. Process có ~11 thread: 4 thread pool libuv (lazy), 4 thread nền V8, DelayedTaskScheduler, main, và 1 thread nội bộ khác. Đo bằng
/proc/self/task. - HTTP request có dùng thread pool không?
- Socket thì không (epoll). Nhưng nếu client gọi tới hostname thì
dns.lookupchạy trong thread pool — đây là nguồn nghẽn bất ngờ (bài 4). - Làm sao Node dùng hết 8 core?
- Nhiều process (cluster/PM2/nhiều pod) cho request;
worker_threadscho việc CPU cụ thể. Không có cách nào để một callback JS chạy trên nhiều core. - Không có lock thì có race condition không?
- Không có data race trong bộ nhớ, nhưng có race logic qua await (lab 5.1). Khoá phải nằm ở DB/Redis hoặc dùng câu lệnh nguyên tử.
- V8 làm gì ngoài chạy JS?
- JIT (Ignition interpreter → Sparkplug/Maglev/TurboFan compile dần theo độ "nóng" của hàm) và GC có thế hệ (bài 6). Tên tầng JIT là theo tài liệu V8, chưa verify trong lab này.
- Vì sao đọc file không dùng epoll?
- epoll từ chối hẳn file thường:
epoll_ctltrảEPERM(thử bằng Python:select.epoll().register(open('/etc/hostname').fileno())→PermissionError [Errno 1] Operation not permitted).select/pollthì luôn báo file "sẵn sàng" dùread()vẫn có thể chặn chờ đĩa. Không có cách "chờ file sẵn sàng" → libuv đọc file trong thread riêng.
Phụ lục: lab đã chạy
Môi trường: Node v22.23.1 (libuv 1.51.0, V8 12.4.254.21) trên Linux 6.8.0, 4 vCPU, 7.8GB RAM,
máy dùng chung. Lab container: image node:22-bookworm-slim (Node 22.23.3), container tạm tên
tmp-node-*, xoá ngay sau khi chạy.
// 20-threads.js
const fs = require('node:fs');
const threads = () => fs.readdirSync('/proc/self/task')
.map((t) => fs.readFileSync(`/proc/self/task/${t}/comm`, 'utf8').trim());
const count = (arr) => Object.entries(arr.reduce((m, n) => (m[n] = (m[n] || 0) + 1, m), {}))
.map(([n, c]) => `${n}×${c}`).join(', ');
console.log('lúc khởi động :', count(threads()));
fs.readFile(__filename, () => console.log('sau 1 fs.readFile:', count(threads())));
// 22-client.mjs — bắn N request đồng thời
const [path, n] = [process.argv[2], +process.argv[3]];
const lat = []; const t0 = performance.now();
await Promise.all(Array.from({ length: n }, async () => {
const s = performance.now();
await (await fetch(`http://127.0.0.1:3100${path}`)).text();
lat.push(performance.now() - s);
}));
lat.sort((a, b) => a - b);
const p = (q) => lat[Math.floor(q * (n - 1))].toFixed(0);
console.log(`${path} x${n}: tổng ${(performance.now() - t0).toFixed(0)}ms | p50 ${p(.5)}ms | p99 ${p(.99)}ms`);
// 23-conn-mem.js (rút gọn) — server đếm RSS trước/sau khi 1 process con mở 5000 kết nối
const srv = net.createServer((s) => { open++; }).listen(0, '127.0.0.1', () => {
global.gc(); const before = mb();
spawn(process.execPath, ['-e', `/* mở ${N} net.connect() rồi giữ 15s */`]);
setTimeout(() => { global.gc(); /* in (after - before) / open */ }, 8000);
});
# Node làm PID 1
docker run -d --name tmp-node-sig node:22-bookworm-slim node -e "setInterval(()=>{},1000)"
docker stop -t 5 tmp-node-sig; docker inspect -f '{{.State.ExitCode}}' tmp-node-sig # 137
docker rm tmp-node-sig
# lặp lại với --init (143) và với process.on('SIGTERM', ...) (0)
# kiểm tra io_uring
curl -sL https://raw.githubusercontent.com/libuv/libuv/v1.51.0/src/unix/linux.c | sed -n '/static int uv__use_io_uring/,/^}/p'
Chưa verify: tên/vai trò chính xác của thread "node" thứ 6; hành vi chuyển tín hiệu của
npm start; số liệu Bun/Deno; các tầng JIT của V8.