Giao diện
Non-blocking I/O & thread pool
Không phải mọi thứ "bất đồng bộ" trong Node đều chạy giống nhau. Một request HTTP, một query MySQL, một lần đọc file và một lần băm mật khẩu đều trả về Promise, nhưng bên dưới chúng đi theo hai con đường khác hẳn nhau: hoặc nhờ hệ điều hành báo khi socket có dữ liệu, hoặc chạy blocking trên một thread pool nhỏ dùng chung. Trang này giải thích hai con đường đó, API nào đi đường nào, và hệ quả khi thread pool bị chiếm hết. Cách event loop điều phối callback có trang chính ở Event loop.
Hai con đường của một thao tác bất đồng bộ
Con đường thứ nhất: kernel báo khi sẵn sàng. Với socket mạng, hệ điều hành có API non-blocking thật sự: epoll trên Linux, kqueue trên macOS, IOCP trên Windows (Process, thread). Main thread đăng ký hàng nghìn socket với kernel, và kernel báo lại socket nào có dữ liệu. Trong lúc chờ không có thread nào bị giữ, nên một process giữ được hàng chục nghìn request đang chờ (Event loop). HTTP, fetch, driver mysql2, pg, ioredis đều đi đường này: chúng chỉ là code JavaScript ghi và đọc trên socket TCP.
Con đường thứ hai: thread pool. Có những việc hệ điều hành không cung cấp API non-blocking dùng được trên mọi nền tảng, hoặc vốn là tính toán nặng. libuv, thư viện bên dưới Node, chạy chúng trên một thread pool: main thread đẩy việc vào một hàng đợi chung, một trong các thread của pool lấy ra, gọi hàm blocking của hệ điều hành hay chạy phép tính, xong thì báo lại event loop để gọi callback. Theo tài liệu Node, những API dùng thread pool là:
| Nhóm | API |
|---|---|
| File | Mọi API callback và Promise của fs, trừ fs.watch (file watcher) và các hàm …Sync |
| DNS | dns.lookup(), mà http.request, fetch, net.connect dùng mặc định khi gọi tới hostname |
| Crypto | crypto.pbkdf2(), scrypt(), randomBytes(), randomFill(), generateKeyPair() |
| Nén | Mọi API bất đồng bộ của zlib |
Pool mặc định có 4 thread, đặt bằng biến môi trường UV_THREADPOOL_SIZE trước khi process khởi động. Pool chỉ được tạo khi có việc đầu tiên cần đến nó.
Mọi API dùng pool xếp chung một hàng
Vì chỉ có một hàng đợi và 4 thread, việc của API này làm chậm việc của API khác, dù hai request không liên quan gì tới nhau. Trên lab, khi 9 lần crypto.pbkdf2 (mỗi lần khoảng 65 ms) chiếm hết pool, một lần đọc file 1 KB vốn chỉ mất 0,1 ms phải chờ khoảng 130 ms, và dns.lookup('localhost'), zlib.gzip cũng vậy. Còn một request HTTP qua socket chỉ chậm từ 0,3 ms lên khoảng 1 ms, vì nó không đi qua pool.
194 msđọc file 1 KB chậm nhất (bình thường 0,1 ms)
19 / 40lần đọc file chậm hơn 5 ms
195 mslần đăng nhập cuối xong
pbkdf2 của request đăng nhậpđọc file của request khácđọc file phải chờ hơn 5 ms
Mọi API dùng thread pool xếp chung một hàng. Khi 12 lần băm mật khẩu chiếm hết 4 thread, một lần đọc file 0,1 ms của request không liên quan phải chờ tới khi có thread rảnh. Tăng số thread thì hết xếp hàng, nhưng với việc tốn CPU như pbkdf2, số thread lớn hơn số core chỉ làm các thread tranh CPU với nhau, mô phỏng này chưa tính phần đó.
Mô phỏng giả định đủ core cho mọi thread. Số đo thật trên M2 (8 core): pool 16 thread vẫn làm đọc file chậm tới 16–21 ms vì 9 việc băm tranh CPU.
Ngoài đời, kịch bản này hay gặp ở API đăng nhập: một đợt nhiều người đăng nhập cùng lúc, mỗi người một lần băm mật khẩu bằng bcrypt hay scrypt, chiếm hết pool. Trong lúc đó, các request khác cần đọc file, ghi log ra file, hay gọi tới một hostname (bước dns.lookup) đều chậm theo. Từ phía ngoài, triệu chứng là latency tăng ở những API chẳng liên quan gì tới đăng nhập.
Một điểm đỡ hơn: fs.readFile đọc file theo từng khúc, mỗi khúc là một việc riêng trên pool, nên đọc file lớn không chiếm một thread suốt từ đầu tới cuối. Trên lab, đọc 4 file 64 MB cùng lúc (từ page cache) chỉ làm một lần đọc file nhỏ chen vào giữa chậm thêm khoảng 1 ms.
Xử lý
- Tăng
UV_THREADPOOL_SIZEkhi việc trên pool chủ yếu là chờ (disk chậm, DNS server trả lời chậm), vì nhiều thread cùng chờ được mà không tranh CPU. Với việc tốn CPU như băm mật khẩu hay nén, thêm thread quá số core không làm nhanh hơn (số đo ở trang Event loop), chỉ bớt xếp hàng. Trên lab, pool 16 thread hết cảnh chờ cả trăm ms, nhưng đọc file vẫn chậm 16–21 ms vì 9 việc băm tranh 8 core. Phải đặt bằng biến môi trường lúc chạy (UV_THREADPOOL_SIZE=16 node app.js); gánprocess.envtrong code thì có thể quá muộn. - Tách việc nặng khỏi pool chung: băm mật khẩu và nén lượng lớn thì chạy ở pool worker riêng (Chặn event loop), hoặc ở service riêng, để không làm chậm file và DNS của cả process.
- Giới hạn số việc nặng cùng lúc, ví dụ rate limit cho API đăng nhập, để một đợt tải không chiếm hết pool.
- Tránh
dns.lookuplặp lại: dùng connection keep-alive (http.Agent({ keepAlive: true }), mặc định trongfetch), để mỗi request không phải mở connection mới và tra DNS lại. Tài liệu Node gợi ý dùngdns.resolve(): hàm này gửi truy vấn DNS qua mạng như một socket, không dùng pool, nhưng không đọc/etc/hostsvà các cấu hình hệ thống màgetaddrinfodùng.
Driver database không dùng thread pool
mysql2, pg, ioredis nói chuyện với database qua socket TCP, nên query không chiếm thread nào của pool trong lúc chờ. Giới hạn số query chạy cùng lúc là connection pool của driver, không phải thread pool của libuv (Node.js với database). Hai pool này hay bị nhầm với nhau trong phỏng vấn:
| Thread pool của libuv | Connection pool của driver | |
|---|---|---|
| Là gì | Các thread của hệ điều hành chạy việc blocking | Các connection TCP mở sẵn tới database |
| Ai dùng | fs, dns.lookup, crypto, zlib | Query của mysql2, pg |
| Mặc định | 4 thread | Tuỳ driver: mysql2 10 connection, pg 10 |
| Cạn thì sao | Việc xếp hàng chờ thread | Query xếp hàng chờ connection |
Riêng bước kết nối tới database bằng hostname cũng gọi dns.lookup, nhưng chỉ lúc mở connection, nên với connection pool giữ connection lâu thì gần như không đáng kể.
Ghi nhớ
- Socket (HTTP,
fetch,mysql2,pg, Redis) dùng epoll/kqueue: chờ không tốn thread nào. fs,dns.lookup, crypto bất đồng bộ vàzlibchạy trên thread pool 4 thread, dùng chung một hàng đợi.- Một đợt băm mật khẩu chiếm hết pool làm chậm đọc file và tra DNS của mọi request khác: đọc file 0,1 ms thành 130 ms trên lab.
- Tăng
UV_THREADPOOL_SIZE(đặt trước khi khởi động) có ích cho việc chờ, ít có ích cho việc tốn CPU; tách việc nặng ra worker riêng. - Thread pool của libuv khác connection pool của driver database.
Nguồn tham khảo
Đã đối chiếu (đọc nguồn ngày 2026-10-26):
| Nguồn | Dùng cho phần |
|---|---|
| Node.js docs, File system: Threadpool usage | Mọi API callback và Promise của fs trừ fs.FSWatcher dùng thread pool; fs.readFile đọc từng khúc, để event loop xoay vòng giữa các khúc |
| Node.js docs, DNS: Implementation considerations | dns.lookup() gọi getaddrinfo đồng bộ trên thread pool; các API mạng gọi nó bên trong; dns.resolve*() không dùng getaddrinfo, luôn truy vấn qua mạng |
Node.js docs, UV_THREADPOOL_SIZE, libuv Design overview | Mặc định 4 thread; danh sách API dùng pool; socket dùng epoll, kqueue, IOCP (đã đối chiếu khi viết trang Event loop) |
Từ kiến thức chung, chưa đối chiếu lần này: kích thước khúc của fs.readFile; connection pool mặc định 10 của mysql2 và pg; fetch mặc định giữ connection.
Số đo chi tiết
Kết quả chạy lab/libuv-thread-pool (MacBook M2, Node 24.14, hai lượt)
Latency của một việc nhỏ lúc rảnh và khi 9 lần pbkdf2 300.000 vòng (khoảng 63–66 ms mỗi lần) đang chạy:
| Việc nhỏ | Lúc rảnh | Pool 4 thread, đang bận | Pool 16 thread, đang bận |
|---|---|---|---|
fs.readFile 1 KB | 0,06–0,08 ms | 131–134 ms | 16–21 ms |
dns.lookup('localhost') | 0,16–0,24 ms | 131–132 ms | 0,4–5 ms |
zlib.gzip 1 KB | 0,10–0,14 ms | 132–158 ms | 10–21 ms |
| HTTP GET tới server cục bộ | 0,29–0,33 ms | 0,75–1,5 ms | 0,7–3,3 ms |
4 lần readFile 64 MB cùng lúc (từ page cache): 17–18 ms; một readFile 1 KB chen vào giữa: 0,9–1,3 ms.
Code đã dùng để chạy thử
Chạy bằng npm run lab -- libuv-thread-pool, chỉ cần Node.js, khoảng 10 giây. Xem Lab.
lab/libuv-thread-pool/shared-pool.mjs
js
// Trang nodejs/libuv-thread-pool: các API dùng thread pool tranh nhau 4 thread, còn socket mạng thì không.
// Đo latency của một việc nhỏ (đọc file 1 KB, dns.lookup, gzip 1 KB, request HTTP tới server cục bộ)
// lúc rảnh, và lúc 8 lần crypto.pbkdf2 đang chiếm thread pool. Chạy với UV_THREADPOOL_SIZE do run.mjs đặt.
import { readFile, writeFile, mkdtemp, rm } from 'node:fs/promises'
import { pbkdf2 } from 'node:crypto'
import { lookup } from 'node:dns/promises'
import { gzip } from 'node:zlib'
import { promisify } from 'node:util'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import http from 'node:http'
const gz = promisify(gzip)
const kdf = promisify(pbkdf2)
const dir = await mkdtemp(join(tmpdir(), 'pool-'))
const small = join(dir, 'small.txt')
await writeFile(small, 'x'.repeat(1024))
const server = http.createServer((req, res) => res.end('ok'))
await new Promise((r) => server.listen(0, '127.0.0.1', r))
const agent = new http.Agent({ keepAlive: true })
const get = () => new Promise((res, rej) => http.get({ host: '127.0.0.1', port: server.address().port, agent }, (r) => { r.resume(); r.on('end', res) }).on('error', rej))
await get()
const probes = {
'fs.readFile 1 KB': () => readFile(small),
'dns.lookup("localhost")': () => lookup('localhost'),
'zlib.gzip 1 KB': () => gz(Buffer.alloc(1024, 'a')),
'HTTP GET tới server cục bộ (socket)': () => get()
}
const ms = (v) => v.toFixed(v < 10 ? 2 : 0).replace('.', ',') + ' ms'
const time = async (fn) => { const t = performance.now(); await fn(); return performance.now() - t }
const size = process.env.UV_THREADPOOL_SIZE || '4 (mặc định)'
console.log(`\n=== UV_THREADPOOL_SIZE = ${size} ===`)
console.log('việc nhỏ lúc rảnh khi 8 pbkdf2 đang chạy')
for (const [name, fn] of Object.entries(probes)) {
await fn()
const idle = Math.min(await time(fn), await time(fn), await time(fn))
const busy = kdf('mật khẩu', 'salt', 300_000, 64, 'sha512')
const all = Array.from({ length: 8 }, () => kdf('mật khẩu', 'salt', 300_000, 64, 'sha512'))
await new Promise((r) => setTimeout(r, 5)) // để các pbkdf2 kịp vào pool trước
const loaded = await time(fn)
await Promise.all([busy, ...all])
console.log(`${name.padEnd(44)} ${ms(idle).padStart(9)} ${ms(loaded).padStart(9)}`)
}
// Một lần pbkdf2 300.000 vòng mất bao lâu, để đối chiếu
console.log(`(một lần pbkdf2 300.000 vòng mất ${ms(await time(() => kdf('mật khẩu', 'salt', 300_000, 64, 'sha512')))})`)
// File lớn: fs.promises.readFile đọc theo từng khúc, mỗi khúc là một việc riêng trên thread pool
const big = join(dir, 'big.bin')
await writeFile(big, Buffer.alloc(64 * 1024 * 1024))
const tBig = performance.now()
const bigs = Array.from({ length: 4 }, () => readFile(big))
await new Promise((r) => setTimeout(r, 2))
const smallDuring = await time(() => readFile(small))
await Promise.all(bigs)
console.log(`4 lần readFile 64 MB cùng lúc mất ${ms(performance.now() - tBig)}; readFile 1 KB chen vào giữa mất ${ms(smallDuring)}`)
agent.destroy(); server.close()
await rm(dir, { recursive: true })