Giao diện
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.
| Process | Thread | |
|---|---|---|
| Bộ nhớ | Riêng, cách ly với process khác | Chung heap với các thread cùng process, stack riêng |
| Giao tiếp | Qua 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ị crash | Các process khác vẫn chạy | Cả process chết theo |
| Chi phí tạo | Đắt hơn | Rẻ 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ách | Khởi động | Bộ 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 ms | RSS của process tăng khoảng 8,5 MB |
| Một async function đang chờ timer | 10.000 cái tạo hết trong 16–28 ms | khoả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
đ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ạy | 8 việc chờ I/O 200 ms | 8 việc tốn CPU |
|---|---|---|
| Một việc | 201–202 ms | 245–322 ms |
Lần lượt (for với await) | 1.609–1.619 ms | 1.744–1.765 ms |
Promise.all trên một thread | 201–203 ms | 1.745–1.769 ms |
| 8 worker | không cần | 318–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ình | Ví dụ | Lúc chờ I/O | Điểm yếu |
|---|---|---|---|
| Mỗi request một process | CGI 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ểm | Process bị block | Tố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 thread | Tomcat, Spring MVC truyền thống (thread pool khoảng 200 thread) | Thread bị block | Request 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 loop | Node.js, Nginx, Redis | Thread chuyển sang việc khác | Mộ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 21 | Runtime xếp thread nhẹ khác lên | Code 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
- (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ò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 Cách viết:
Request A
Bước tiếp theo:
SELECT stock FROM products WHERE id = 1
Request B
Bước tiếp theo:
SELECT stock FROM products WHERE id = 1
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.
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ểm | macOS | Windows |
|---|---|---|
| Vừa khởi động | 7 | 13 |
Sau lần đầu gọi crypto.pbkdf2 | 11 | 17 |
| Sau khi tạo một worker | 12 | 18 |
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ách | Là gì | Dùng khi |
|---|---|---|
Nhiều process: cluster, PM2, nhiều container | Mỗi process một event loop riêng, connection được chia cho các process | Scale HTTP server theo số core (Scale Node.js) |
worker_threads | Thread có V8 riêng, trao đổi với main thread bằng message | Việ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_process | Chạy một chương trình khác | Gọ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ớ
- 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.
- 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.
- 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.allkhông làm việc CPU nhanh hơn. - 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.
- 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_threadscho việc tốn CPU.
Nguồn tham khảo
Đã đối chiếu (đọc nguồn ngày 2026-10-20):
| Nguồn | Dù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 overview | Network 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_SIZE | Mặ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
maxThreads200; 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ệuworker_threads. worker_threadsdocs: mỗi worker có V8 isolate và event loop riêng,SharedArrayBuffervà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))}`)