Skip to content

Process, thread & concurrency ​

Trang này là phần nền tảng về hệ điều hành, cần có trước khi đọc Event loop, worker_threads hay cluster. Nó trả lời ba câu hỏi: process và thread khác nhau thế nào, concurrency khác parallelism thế nào, và vì sao một server có thể phục vụ hàng nghìn request bằng một thread. Số liệu trong trang là số đo thật bằng script lab/process-thread/ trên MacBook M2.

Process và thread ​

Chương trình là file nằm trên đĩa. Process là một chương trình đang chạy. Hệ điều hành cấp cho mỗi process:

  • Vùng nhớ riêng. Process này không đọc hay ghi được bộ nhớ của process khác. Muốn trao đổi dữ liệu thì phải đi qua kênh của hệ điều hành (pipe, socket, IPC).
  • Danh sách tài nguyên đang mở, gồm file, socket, connection. Mỗi thứ được đánh một số gọi là file descriptor.
  • Ít nhất một thread.

Thread là một luồng chạy lệnh bên trong process. Mỗi thread có stack riêng, chứa biến cục bộ và các lời gọi hàm đang dở, và có vị trí lệnh đang chạy riêng. Còn heap thì mọi thread trong cùng process dùng chung. Vì dùng chung bộ nhớ nên các thread trao đổi dữ liệu rất nhanh, chỉ cần đọc cùng một biến. Nhưng cũng vì vậy mà hai thread cùng sửa một biến thì có thể ghi đè lên nhau (xem Race condition).

Có thể hình dung process là một căn hộ có khoá riêng, còn thread là những người sống trong căn hộ đó, dùng chung bếp và tủ lạnh.

ProcessThread
Bộ nhớRiêng, cách ly với process khácChung heap với các thread cùng process, stack riêng
Giao tiếpQua hệ điều hành (pipe, socket), phải chép dữ liệuĐọc ghi chung biến, nhanh nhưng phải đồng bộ
Một cái bị crashCác process khác vẫn chạyCả process chết theo
Chi phí tạoĐắt hơnRẻ hơn

Kết quả đo thực tế ba cách chạy nhiều việc trong Node.js, ba lượt ngày 2026-10-20:

CáchKhởi độngBộ nhớ thêm cho mỗi cái
Process con (node -e …)22–24 ms (khởi động và thoát)RSS của process con khoảng 38 MB
Một worker (module worker_threads)15–16 msRSS của process tăng khoảng 8,5 MB
Một async function đang chờ timer10.000 cái tạo hết trong 16–28 mskhoảng 840 byte heap

RSS là phần RAM thật mà process đang chiếm. Có hai điều cần lưu ý khi đọc bảng:

  • 38 MB là con số cao hơn chi phí thật. RSS tính cả phần code của Node, và phần này hệ điều hành cho các process dùng chung. Vì vậy cộng RSS của 10 process con sẽ ra nhiều hơn lượng RAM thật sự tốn thêm.
  • 840 byte gồm cả object timer của lời gọi sleep() mà hàm đang chờ, không chỉ riêng async function.

Một worker của Node nặng hơn thread thông thường nhiều, vì mỗi worker có một bản V8 riêng với heap và event loop riêng. Còn một async function đang chờ chỉ là vài object nhỏ trong heap, nên giữ hàng chục nghìn cái cùng lúc vẫn rẻ.

CPU, core và scheduler ​

Mỗi core của CPU chạy được một thread tại một thời điểm. Máy 8 core chạy được tối đa 8 thread cùng lúc theo đúng nghĩa (bỏ qua hyper-threading, kỹ thuật cho một core chạy xen hai thread). Nhưng trên máy luôn có hàng trăm thread, nên hệ điều hành có một bộ lập lịch (scheduler) chia CPU cho chúng. Mỗi thread luôn ở một trong ba trạng thái:

  • Running: đang chạy trên một core.
  • Ready: sẵn sàng chạy, chờ tới lượt có core.
  • Blocked: đang chờ một việc khác xong (đọc disk, nhận dữ liệu mạng, chờ lock, sleep). Thread bị block không tốn CPU, và scheduler không đưa nó lên core cho tới khi việc nó chờ xong.

Khi một thread dùng hết lượt của mình (time slice, từ vài ms trên Linux tới vài chục ms trên Windows bản desktop), scheduler đưa thread khác lên core. Việc đổi thread này gọi là context switch: hệ điều hành lưu trạng thái thanh ghi của thread cũ, nạp trạng thái của thread mới. Mỗi lần chỉ tốn vài micro giây, nhưng thread mới phải nạp lại dữ liệu của nó vào cache CPU. Vì vậy hàng nghìn thread tranh nhau vài core thì máy tốn đáng kể thời gian cho việc chuyển qua lại.

Preemptive và cooperative ​

Scheduler của hệ điều hành là preemptive, tức nó chủ động ngắt một thread khi hết lượt, dù code của thread đó đang chạy dở một vòng lặp. Vì vậy một thread chạy vòng lặp vô hạn không làm treo các thread khác.

Event loop thì cooperative. Event loop không ngắt được một callback đang chạy, mà phải chờ callback tự kết thúc hoặc gặp await thì mới chuyển sang việc khác. Vì vậy một đoạn JavaScript chạy lâu làm mọi request khác trong process phải chờ, như trang Event loop đã đo.

Concurrency và parallelism ​

Rob Pike (một trong những người tạo ra Go) phân biệt hai khái niệm này như sau: concurrency is about dealing with lots of things at once, parallelism is about doing lots of things at once. Tạm dịch: concurrency là lo liệu nhiều việc cùng lúc, còn parallelism là thực hiện nhiều việc cùng lúc.

  • Concurrency là khả năng lo nhiều việc trong cùng một khoảng thời gian, cho các việc xen kẽ nhau tiến lên. Tại một thời điểm có thể chỉ một việc đang thật sự chạy.
  • Parallelism là nhiều việc thật sự chạy cùng một thời điểm, trên nhiều core.

Ví dụ quầy cà phê có một nhân viên. Nhân viên nhận order của khách 1, bật máy pha, rồi trong lúc máy chạy thì nhận order của khách 2. Một người lo được nhiều khách như vậy gọi là concurrency. Quầy có ba nhân viên với ba máy pha cùng làm một lúc, đó là parallelism. Concurrency không cần nhiều core, còn parallelism thì bắt buộc phải có.

Loại việc
Cách chạy

Thread chạy hết việc này mới sang việc khác. Lúc chờ I/O thread đứng yên nhưng vẫn giữ CPU. Bốn việc A–D cùng đến lúc 0, mỗi việc: 10 ms CPU, chờ database 60 ms, 10 ms CPU.

320 msxong cả bốn việc
200 msthời gian xong trung bình
25%thời gian CPU thật sự tính toán
Core 1AAAAAAAABBBBBBBBCCCCCCCCDDDDDDDDViệc A80 msViệc B160 msViệc C240 msViệc D320 ms04080120160200240280320
đang chạy trên CPUchờ I/Osẵn sàng nhưng chờ tới lượtthread bị chặn, giữ core

Thread chờ database phần lớn thời gian, CPU gần như không làm gì nhưng việc sau vẫn phải xếp hàng. Đây là lý do server I/O chặn cần rất nhiều thread.

CPU-bound và I/O-bound ​

Mô phỏng trên cho thấy kết quả phụ thuộc vào loại việc:

  • I/O-bound là việc phần lớn thời gian chờ thứ khác: chờ database, chờ service khác, chờ disk. API backend thường thuộc loại này. Một request mất 50 ms có khi chỉ tốn 2–5 ms CPU, phần còn lại là chờ.
  • CPU-bound là việc tính toán liên tục, không chờ gì: băm mật khẩu, nén, resize ảnh, parse một JSON rất lớn.

Concurrency có ích cho việc I/O-bound, vì có thời gian chờ để xen việc khác vào. Việc CPU-bound thì không có lúc chờ nào để xen, nên chỉ parallelism mới làm nó nhanh hơn. Đo thật trên Node.js với 8 việc, mỗi việc hoặc chờ timer 200 ms (giả lập chờ database), hoặc chạy một vòng lặp tính toán khoảng 250 ms:

Cách chạy8 việc chờ I/O 200 ms8 việc tốn CPU
Một việc201–202 ms245–322 ms
Lần lượt (for với await)1.609–1.619 ms1.744–1.765 ms
Promise.all trên một thread201–203 ms1.745–1.769 ms
8 workerkhông cần318–366 ms

Dòng Promise.all là chỗ dễ hiểu nhầm. Với việc chờ I/O, 8 việc xong trong thời gian của một việc. Với việc tốn CPU, Promise.all không nhanh hơn chạy lần lượt chút nào, vì cả 8 hàm async vẫn chạy trên cùng một thread. Từ khoá async chỉ cho phép một hàm chờ mà không chặn thread, chứ không tự tạo thêm thread.

8 worker trên máy 8 core nhanh hơn khoảng 5 lần (4,8–5,5 lần qua ba lượt) chứ không phải 8 lần. Lý do là M2 có 4 core hiệu năng cao và 4 core tiết kiệm điện chạy chậm hơn, mỗi worker tốn khoảng 15 ms để khởi động, và các process khác trên máy cũng dùng CPU. Phần khởi động worker và gom kết quả không chạy song song được, nên tốc độ tăng còn bị chặn bởi định luật Amdahl (Latency).

Blocking và non-blocking I/O ​

Khi một thread gọi lệnh đọc socket theo kiểu blocking, hệ điều hành đặt thread đó vào trạng thái blocked cho tới khi có dữ liệu. Code dễ viết, vì đọc xong mới chạy dòng tiếp theo. Nhưng thread đó không làm được gì khác trong lúc chờ, nên muốn phục vụ 1.000 connection cùng lúc thì cần 1.000 thread.

Theo kiểu non-blocking, lệnh đọc trả về ngay. Nếu chưa có dữ liệu thì nó báo "chưa có" thay vì chờ. Lúc này chương trình cần một cách để biết khi nào socket có dữ liệu mà không phải hỏi đi hỏi lại. Hệ điều hành cung cấp hai kiểu cơ chế cho việc này:

  • epoll (Linux) và kqueue (macOS, BSD) báo socket nào đã sẵn sàng để đọc hay ghi (readiness). Chương trình đăng ký hàng nghìn socket, gọi một lệnh chờ duy nhất, và nhận về danh sách socket sẵn sàng. Sau đó chương trình tự đọc.
  • IOCP (Windows) báo thao tác nào đã xong (completion). Chương trình giao trước lệnh đọc kèm buffer, hệ điều hành đọc vào buffer rồi báo khi đọc xong.

libuv giấu sự khác nhau này, nên code Node.js chạy như nhau trên cả ba hệ điều hành. Với cả hai kiểu, một thread canh được hàng chục nghìn connection.

File thường trên disk thì khác. Không phải hệ điều hành nào cũng có API non-blocking dùng được cho file, nên libuv (thư viện bên dưới Node.js) chạy các lệnh đọc ghi file blocking trên một thread pool riêng (Event loop).

Các cách một server phục vụ nhiều request ​

Mô hìnhVí dụLúc chờ I/OĐiểm yếu
Mỗi request một processCGI tạo process mới cho mỗi request; Apache prefork và PHP-FPM giữ sẵn một pool process, mỗi process xử lý một request tại một thời điểmProcess bị blockTốn bộ nhớ nhất, mỗi process hàng chục MB; CGI còn tốn thêm thời gian tạo process
Mỗi request một threadTomcat, Spring MVC truyền thống (thread pool khoảng 200 thread)Thread bị blockRequest nhiều hơn số thread thì xếp hàng; hàng nghìn thread thì tốn bộ nhớ và context switch
Event loopNode.js, Nginx, RedisThread chuyển sang việc khácMột đoạn code chạy lâu chặn mọi request; một process chỉ dùng một core
Thread nhẹ do runtime quản lýGoroutine của Go, virtual thread của Java 21Runtime xếp thread nhẹ khác lênCode viết như mỗi request một thread, runtime dùng epoll ở bên dưới

Mô hình event loop phổ biến từ đầu những năm 2000 vì bài toán C10K, tức làm sao một máy phục vụ được 10.000 connection cùng lúc. Với máy thời đó, một thread cho mỗi connection nghĩa là 10.000 thread, làm cạn bộ nhớ và CPU tốn quá nhiều thời gian chuyển qua lại. Máy ngày nay chạy được 10.000 thread, nhưng vẫn tốn bộ nhớ cho stack và chi phí context switch hơn hẳn event loop. Trang Event loop đo một process Node.js giữ 10.000 request cùng chờ mà vẫn chỉ có 8 thread.

Race condition: cái giá của bộ nhớ dùng chung ​

Câu lệnh x = x + 1 thực ra gồm ba bước: đọc x, cộng 1, ghi lại. Hai thread cùng chạy câu này lúc x = 10 có thể cùng đọc ra 10, cùng ghi lại 11, và mất một lần cộng. Lỗi này gọi là race condition. Các ngôn ngữ có nhiều thread chống nó bằng lock (mutex) hoặc lệnh atomic. Lock lại sinh ra lỗi khác là deadlock: thread 1 giữ lock A và chờ lock B, trong khi thread 2 giữ lock B và chờ lock A, nên cả hai chờ nhau mãi (Deadlock trong MySQL).

Tự làm scheduler để thấy lỗi mất một lần cộng, rồi bật mutex để thấy thread kia bị block:

Code của mỗi thread

Hai thread cùng chạy x = x + 1 trên một biến dùng chung, bắt đầu từ x = 10, nên kết quả đúng phải là 12. Bấm nút của từng thread để tự quyết định thread nào chạy bước tiếp, giống scheduler của hệ điều hành có thể ngắt một thread ở bất kỳ đâu.

x = 10biến dùng chung
T1 · thanh ghi riêng: —
1reg = x       // đọc2reg = reg + 1 // cộng3x = reg       // ghi
T2 · thanh ghi riêng: —
1reg = x       // đọc2reg = reg + 1 // cộng3x = reg       // ghi
Đã xảy ra
  1. (chưa có bước nào)

JavaScript trên main thread của Node.js không bị lỗi ở mức này, vì tại mỗi thời điểm chỉ một đoạn JavaScript được chạy và không ai chen ngang được một đoạn code đồng bộ. Nhưng mỗi await là một chỗ request khác có thể chen vào giữa:

js
// Hai request rút tiền cùng lúc: cả hai đều đọc số dư 100 trước khi request nào kịp ghi
const balance = await getBalance(userId) // request khác chạy ở đây
if (balance >= amount) {
  await setBalance(userId, balance - amount)
}

Cách chặn lỗi này nằm ở database (lệnh ghi có điều kiện, lock dòng), vì app thường chạy nhiều instance (Chống trùng & race condition). Mô phỏng dưới (cùng mô phỏng ở trang đó) cho hai request A và B chạy cùng một đoạn đọc rồi ghi. Mỗi await là một chỗ có thể đổi sang request kia, và có thể bật các cách chặn:

Thử: hai request chạy xen kẽ
Cách viết:
Request A
Request B
products.stock1
orderstrống

Gợi ý: bấm A, B, A, B… để hai request chen nhau. Với cách viết có lỗi, chạy lần lượt thì đúng nhưng chạy xen kẽ thì sai; đó là lý do race condition hiếm khi lộ ra khi test bằng tay. Ở cách FOR UPDATE, B bị chặn ở bước đầu cho tới khi A COMMIT.

Còn các worker không dùng chung biến; chúng gửi message cho nhau, và message được chép sang. Có hai ngoại lệ. Khi gửi kèm danh sách transfer (ví dụ một ArrayBuffer), dữ liệu được chuyển hẳn sang worker kia chứ không chép, và bên gửi không dùng được nó nữa. Khi dùng SharedArrayBuffer thì các worker thật sự chung một vùng nhớ, và lúc đó phải dùng Atomics.

Node.js trong bức tranh này ​

Câu “Node.js là single-thread” chỉ đúng một nửa. JavaScript chạy trên một thread (main thread), nhưng process Node.js có nhiều thread. Đo với Node 24 trên hai hệ điều hành (macOS đếm bằng ps -M, Windows hỏi PowerShell):

Thời điểmmacOSWindows
Vừa khởi động713
Sau lần đầu gọi crypto.pbkdf21117
Sau khi tạo một worker1218

Số thread lúc khởi động khác nhau, vì V8 và libuv tạo thread theo từng hệ điều hành. Nhưng phần tăng thêm thì giống nhau: thêm 4 thread của thread pool libuv, rồi thêm 1 thread cho worker. Ngoài main thread, mấy thread có sẵn từ đầu là của V8 (GC chạy nền, biên dịch code). Thread pool của libuv chỉ được tạo khi có việc đầu tiên cần đến nó, và vì chỉ có 4 thread nên việc thứ 5 phải xếp hàng (đo 8 lần pbkdf2 cùng lúc).

Main thread chạy JavaScript và event loop, nên phần JavaScript của ứng dụng chỉ dùng được một core. Cả process thì vẫn dùng nhiều core hơn một chút, vì GC và thread pool chạy trên các thread khác. Muốn JavaScript của ứng dụng dùng nhiều core thì có ba cách:

CáchLà gìDùng khi
Nhiều process: cluster, PM2, nhiều containerMỗi process một event loop riêng, connection được chia cho các processScale HTTP server theo số core (Scale Node.js)
worker_threadsThread có V8 riêng, trao đổi với main thread bằng messageViệc tốn CPU trong cùng service (resize ảnh, parse lớn). Dùng một pool worker có sẵn (ví dụ thư viện piscina), không tạo worker mới cho mỗi request
child_processChạy một chương trình khácGọi ffmpeg, script viết bằng ngôn ngữ khác

Trang tiếp theo, Event loop, đi vào chi tiết cách main thread xen kẽ nhiều request.

Ghi nhớ

  1. Process có bộ nhớ riêng và cách ly với nhau. Thread trong cùng process dùng chung heap, nên nhanh khi trao đổi dữ liệu nhưng dễ bị race condition.
  2. Concurrency là lo nhiều việc xen kẽ trong cùng một khoảng thời gian, parallelism là nhiều việc chạy cùng một thời điểm. Concurrency không cần nhiều core.
  3. Việc chờ I/O được lợi từ concurrency. Việc tốn CPU chỉ nhanh hơn khi chạy song song trên nhiều core. Promise.all không làm việc CPU nhanh hơn.
  4. Non-blocking I/O kết hợp epoll/kqueue cho phép một thread canh hàng nghìn connection. Đó là nền của Node.js, Nginx, Redis.
  5. Node.js chạy JavaScript trên một thread, nhưng process có nhiều thread (V8, thread pool của libuv). Muốn dùng nhiều core thì chạy nhiều process, hoặc dùng worker_threads cho việc tốn CPU.

Nguồn tham khảo ​

Đã đối chiếu (đọc nguồn ngày 2026-10-20):

NguồnDùng cho phần
Andrew Gerrand, Concurrency is not parallelism, Go Blog, 2013 (giới thiệu bài nói của Rob Pike)Câu định nghĩa concurrency và parallelism
libuv docs, Design overviewNetwork I/O chạy trên một thread bằng epoll, kqueue, event ports, IOCP; file I/O, DNS và uv_queue_work chạy trên thread pool vì không có API file I/O của hệ điều hành để dựa vào
Node.js docs, UV_THREADPOOL_SIZEMặc định 4 thread, các API dùng thread pool

Từ kiến thức chung, chưa đối chiếu lần này:

  • Các trạng thái của thread, time slice, chi phí context switch vài micro giây (sách hệ điều hành, ví dụ Operating Systems: Three Easy Pieces của Remzi và Andrea Arpaci-Dusseau, có bản đọc miễn phí tại ostep.org).
  • Dan Kegel, The C10K problem, 1999.
  • Tomcat mặc định maxThreads 200; goroutine và virtual thread (JEP 444, Java 21) chạy trên số ít thread của hệ điều hành.
  • Các thread có sẵn khi Node.js khởi động là của V8 (GC, biên dịch). Con số 7 (macOS, Node 24.14) và 13 (Windows, Node 24.13, tác giả chạy ngày 2026-10-20) chỉ đúng với hai môi trường đó.
  • Time slice của scheduler Linux và Windows; IOCP theo mô hình completion; Apache prefork và PHP-FPM giữ pool process; RSS tính cả trang nhớ dùng chung.
  • Danh sách transfer của postMessage (ArrayBuffer, MessagePort) trong tài liệu worker_threads.
  • worker_threads docs: mỗi worker có V8 isolate và event loop riêng, SharedArrayBuffer và Atomics.

Code đã dùng để chạy thử ​

Chạy bằng npm run lab -- process-thread (chỉ cần Node.js, khoảng 20 giây). Số trong trang lấy từ ba lượt chạy ngày 2026-10-20 trên MacBook M2 (Node 24.14, libuv 1.51), xem Lab.

lab/process-thread/threads.mjs: đếm thread của process
js
// Một process Node.js có bao nhiêu thread của hệ điều hành, và thread pool của libuv được tạo lúc nào.
// Cách đếm thread theo từng hệ điều hành nằm trong common/threads.mjs.
import { pbkdf2 } from 'node:crypto'
import { Worker } from 'node:worker_threads'
import { promisify } from 'node:util'
import { countThreads } from '../common/threads.mjs'

console.log('\n=== Số thread của một process Node.js ===')
console.log(`Node ${process.version}, libuv ${process.versions.uv}, ${process.platform}, pid ${process.pid}`)
console.log(`Vừa khởi động:                      ${countThreads()} thread`)

// Thread pool của libuv chỉ được tạo khi có việc đầu tiên cần đến nó (crypto, fs, zlib, dns.lookup).
await promisify(pbkdf2)('pw', 'salt', 1000, 32, 'sha256')
console.log(`Sau một lần crypto.pbkdf2:          ${countThreads()} thread  (thêm thread pool, mặc định 4)`)

const w = new Worker('setInterval(() => {}, 1000)', { eval: true })
await new Promise((r) => w.once('online', r))
console.log(`Sau khi tạo một worker:             ${countThreads()} thread`)
await w.terminate()
lab/common/threads.mjs: cách đếm thread trên Linux, macOS, Windows
js
// Đếm số thread hệ điều hành của một process.
//   Linux:   đếm thư mục /proc/<pid>/task
//   macOS:   `ps -M -p <pid>` in một dòng cho mỗi thread, cộng một dòng tiêu đề
//   Windows: không có `ps`, hỏi PowerShell (mất vài trăm ms mỗi lần gọi)
// Số thread khác nhau giữa các hệ điều hành, vì V8 và libuv tạo thread theo từng nền tảng.
import { execSync } from 'node:child_process'
import { readdirSync } from 'node:fs'

export function countThreads(pid = process.pid) {
  if (process.platform === 'linux') return readdirSync(`/proc/${pid}/task`).length
  if (process.platform === 'win32') {
    const out = execSync(`powershell.exe -NoProfile -NonInteractive -Command "(Get-Process -Id ${pid}).Threads.Count"`)
    return Number(out.toString().trim())
  }
  return execSync(`ps -M -p ${pid}`).toString().trim().split('\n').length - 1
}
lab/process-thread/cost.mjs: chi phí process, worker, async function
js
// Chi phí của ba cách chạy "nhiều việc cùng lúc" trong Node.js: process con, worker (worker_threads), async function.
// Đo thời gian khởi động và bộ nhớ (RSS: phần RAM thật process đang chiếm).
// Chạy với `node --expose-gc` (run.mjs đã thêm cờ này) để global.gc() dọn heap trước phép đo cuối;
// chạy thẳng không có cờ thì global.gc không tồn tại và bước dọn bị bỏ qua.
import { spawn } from 'node:child_process'
import { Worker } from 'node:worker_threads'
import { setTimeout as sleep } from 'node:timers/promises'
import { performance } from 'node:perf_hooks'

const N = 10
const mb = (b) => (b / 1024 / 1024).toFixed(1).replace('.', ',') + ' MB'
const avg = (a) => (a.reduce((x, y) => x + y, 0) / a.length).toFixed(1).replace('.', ',') + ' ms'

console.log('\n=== Chi phí khởi động và bộ nhớ ===')

// 1. Process con: khởi động node mới, chờ nó báo RSS rồi thoát.
const procTimes = [], procRss = []
for (let i = 0; i < N; i++) {
  const t0 = performance.now()
  const child = spawn(process.execPath, ['-e', 'console.log(process.memoryUsage().rss)'])
  let out = ''
  child.stdout.on('data', (d) => (out += d))
  await new Promise((r) => child.on('exit', r))
  procTimes.push(performance.now() - t0)
  procRss.push(Number(out))
}
console.log(`Process con (node -e):  khởi động + thoát trung bình ${avg(procTimes)}, RSS mỗi process ${mb(procRss[N - 1])}`)

// 2. Worker: từ lúc tạo tới lúc worker gửi được message đầu tiên.
const workerTimes = []
const rss0 = process.memoryUsage().rss
const workers = []
for (let i = 0; i < N; i++) {
  const t0 = performance.now()
  const w = new Worker("require('node:worker_threads').parentPort.postMessage('hi'); setInterval(() => {}, 1000)", { eval: true })
  await new Promise((r) => w.once('message', r))
  workerTimes.push(performance.now() - t0)
  workers.push(w)
}
await sleep(200)
const perWorker = (process.memoryUsage().rss - rss0) / N
console.log(`Worker:                 khởi động trung bình ${avg(workerTimes)}, RSS tăng khoảng ${mb(perWorker)} mỗi worker`)
await Promise.all(workers.map((w) => w.terminate()))

// 3. Async function đang chờ: 10.000 hàm, mỗi hàm chờ một timer. Phần heap đo được gồm cả object timer.
global.gc?.()
await sleep(200)
const rss1 = process.memoryUsage().rss
const heap1 = process.memoryUsage().heapUsed
const t0 = performance.now()
const tasks = Array.from({ length: 10_000 }, async () => {
  await sleep(500)
})
const created = performance.now() - t0
const heap2 = process.memoryUsage().heapUsed
console.log(`10.000 async function:  tạo hết trong ${created.toFixed(1).replace('.', ',')} ms, heap tăng ${mb(heap2 - heap1)} (khoảng ${Math.round((heap2 - heap1) / 10_000)} byte mỗi việc), RSS tăng ${mb(process.memoryUsage().rss - rss1)}`)
await Promise.all(tasks)
lab/process-thread/cpu-vs-io.mjs: concurrency và parallelism
js
// Concurrency khác parallelism. 8 việc chờ I/O và 8 việc tốn CPU, chạy theo ba cách:
//   tuần tự, Promise.all trên một thread (concurrency), 8 worker của worker_threads (parallelism).
// Máy đo: Apple M2, 8 core (4 performance + 4 efficiency).
import { Worker } from 'node:worker_threads'
import { setTimeout as sleep } from 'node:timers/promises'
import { performance } from 'node:perf_hooks'
import { cpus } from 'node:os'

const N = 8
const fmt = (ms) => String(Math.round(ms)).padStart(5) + ' ms'

// Việc tốn CPU: vòng lặp tính toán thuần, không chờ gì. Số vòng chọn để mất khoảng 250 ms trên M2.
const CPU_WORK = `
  function work() {
    let x = 0
    for (let i = 0; i < 60_000_000; i++) x = (x + i * 7) % 1_000_003
    return x
  }`
const work = new Function(`${CPU_WORK}; return work`)()
const cpuAsync = async () => work() // async không làm việc CPU chạy song song

// Việc chờ I/O: giả lập chờ database 200 ms bằng timer. Trong lúc chờ thread không làm gì.
const ioTask = () => sleep(200)

async function time(fn) {
  const t0 = performance.now()
  await fn()
  return performance.now() - t0
}

function inWorkers() {
  return Promise.all(
    Array.from({ length: N }, () => new Promise((resolve, reject) => {
      const w = new Worker(`${CPU_WORK}; require('node:worker_threads').parentPort.postMessage(work())`, { eval: true })
      w.once('message', () => resolve(w.terminate()))
      w.once('error', reject)
    }))
  )
}

work() // khởi động JIT
console.log(`\n=== ${N} việc, máy có ${cpus().length} core ===`)
console.log('                                  chờ I/O 200 ms     tốn CPU')
console.log(`Một việc                          ${fmt(await time(ioTask))}         ${fmt(await time(async () => work()))}`)
console.log(`Tuần tự (for + await)             ${fmt(await time(async () => { for (let i = 0; i < N; i++) await ioTask() }))}         ${fmt(await time(async () => { for (let i = 0; i < N; i++) await cpuAsync() }))}`)
console.log(`Promise.all trên một thread       ${fmt(await time(() => Promise.all(Array.from({ length: N }, ioTask))))}         ${fmt(await time(() => Promise.all(Array.from({ length: N }, cpuAsync))))}`)
console.log(`${N} worker                          (không cần)       ${fmt(await time(inWorkers))}`)