Skip to content

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ộ ​

Main threadevent loopKernelepoll / kqueue / IOCPsocket: HTTP, mysql2,pg, Redis, fetchđăng kýHàng đợi chungFIFO, mọi API dùng poolThread pool4 thread (mặc định)fs, dns.lookup, crypto, zlibđẩy việcxong thì báo lại event loop
Socket không tốn thread nào trong lúc chờ: kernel báo khi có dữ liệu. File, DNS lookup, crypto và zlib chạy blocking trên một thread pool nhỏ, dùng chung một hàng đợi.

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ómAPI
FileMọi API callback và Promise của fs, trừ fs.watch (file watcher) và các hàm …Sync
DNSdns.lookup(), mà http.request, fetch, net.connect dùng mặc định khi gọi tới hostname
Cryptocrypto.pbkdf2(), scrypt(), randomBytes(), randomFill(), generateKeyPair()
NénMọ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
thread 1thread 2thread 3thread 4latency đọc file0 ms100 ms200 ms300 ms400 ms
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_SIZE khi 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án process.env trong 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.lookup lặp lại: dùng connection keep-alive (http.Agent({ keepAlive: true }), mặc định trong fetch), để mỗi request không phải mở connection mới và tra DNS lại. Tài liệu Node gợi ý dùng dns.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/hosts và các cấu hình hệ thống mà getaddrinfo dù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 libuvConnection pool của driver
Là gìCác thread của hệ điều hành chạy việc blockingCác connection TCP mở sẵn tới database
Ai dùngfs, dns.lookup, crypto, zlibQuery của mysql2, pg
Mặc định4 threadTuỳ driver: mysql2 10 connection, pg 10
Cạn thì saoViệc xếp hàng chờ threadQuery 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ớ

  1. Socket (HTTP, fetch, mysql2, pg, Redis) dùng epoll/kqueue: chờ không tốn thread nào.
  2. fs, dns.lookup, crypto bất đồng bộ và zlib chạy trên thread pool 4 thread, dùng chung một hàng đợi.
  3. 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.
  4. 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.
  5. 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ồnDùng cho phần
Node.js docs, File system: Threadpool usageMọ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 considerationsdns.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 overviewMặ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ảnhPool 4 thread, đang bậnPool 16 thread, đang bận
fs.readFile 1 KB0,06–0,08 ms131–134 ms16–21 ms
dns.lookup('localhost')0,16–0,24 ms131–132 ms0,4–5 ms
zlib.gzip 1 KB0,10–0,14 ms132–158 ms10–21 ms
HTTP GET tới server cục bộ0,29–0,33 ms0,75–1,5 ms0,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 })