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à image node: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:

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.

Đường đi của I/O mạng và I/O file trong Node Main thread chạy JS. Request mạng đi xuống libuv rồi tới epoll của kernel, main thread rảnh trong lúc chờ. Request đọc file đi sang thread pool, một worker bận. Khi xong, cả hai quay lại main thread dưới dạng callback. Main thread — V8 chạy JS của bạn app.js → lib/http.js, lib/fs.js → binding C++ rảnh: chạy request khác libuv — event loop quyết định: socket → epoll · file/DNS/crypto → thread pool Kernel: epoll theo dõi hàng nghìn socket bằng 1 syscall, 0 thread chờ Thread pool (UV_THREADPOOL_SIZE=4) read() socket fs.readFile
Hình 1 — Chấm xanh (socket) đi xuống epoll của kernel: không có thread nào ngồi chờ. Chấm vàng (đọc file) sang thread pool: một worker thread bận thật sự trong lúc đọc. Trong cả hai trường hợp, main thread rảnh để xử lý request khác; kết quả quay về dưới dạng callback ở poll phase.

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

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ệcChạy ở đâuVì sao
TCP, UDP, HTTP, pipe, TTYMain thread + epollKernel có API báo "socket sẵn sàng" → không cần thread chờ.
fs.* (trừ fs.watch)Thread poolepoll 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 poolGọ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 asyncThread poolViệc CPU — đẩy ra khỏi main thread.
Code JS của bạnMain threadLuô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

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ệcMặc địnhKhi nào đổi
Số process1Luôn chạy N process = số core (cluster, PM2) hoặc N pod × 1 process với CPU limit ~1 core.
UV_THREADPOOL_SIZE4Nhiề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-sizeTheo RAM / cgroupChạy trong container — bài 6.
Xử lý SIGTERMKhông cóLuôn tự viết graceful shutdown (5.2).
ulimit -nThường 1024Server 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ụcNode.jsPHP-FPM (Laravel)GoJava/SpringBun / Deno
Hiệu năng I/O đồng thờiCaoThấp–TB (giới hạn bởi số worker)CaoCao (virtual thread)Cao
Hiệu năng CPUTB (1 core/process)TBCaoCaoTB
Cô lập lỗiThấp (process chung)Cao (process/request)TB (panic giết process)TBThấp
Độ phức tạp vận hànhTBThấpThấp (1 binary)Cao (JVM tuning)TB
Hệ sinh tháiRất lớn (npm)LớnTBRất lớnTương thích npm phần lớn (chưa verify)
Độ trưởng thànhCao (từ 2009)CaoCaoRất caoThấp hơn
Tuyển ngườiRất dễDễTBDễ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

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

  1. Biến module-level chứa ngữ cảnh request (currentUser, currentTenant) — lộ dữ liệu giữa user (5.1).
  2. Đọc-sửa-ghi qua await — lost update (5.1).
  3. Chạy 1 process Node trên máy 8 core và thắc mắc sao CPU chỉ 12%.
  4. Container không xử lý SIGTERM, chạy qua npm start (5.2).
  5. Dùng API *Sync (readFileSync, execSync) trong code chạy mỗi request — chặn cả process.
  6. Nghĩ async = chạy song song — chỉ là concurrency trên một thread.
  7. 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.lookup chạ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_threads cho 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_ctl trả EPERM (thử bằng Python: select.epoll().register(open('/etc/hostname').fileno()) → PermissionError [Errno 1] Operation not permitted). select/poll thì 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.