Bài 10 · Nâng cao · 28 phút· Cập nhật 11/06/2026
Đa luồng & đa tiến trình Node.js
Biên soạn bởi Nguyễn Anh Tuấn
Đa luồng & đa tiến trình trong Node.js: Worker Threads, SharedArrayBuffer & Atomics, child_process, cluster - phân biệt I/O-bound với CPU-bound.
Dù máy bạn có 8 hay 16 lõi CPU, code JavaScript của bạn - theo mặc định - chạy trên đúng một luồng. Ở khoá Cơ bản (bài Bất đồng bộ) ta đã thấy event loop khéo léo xen kẽ các việc chờ I/O để không đứng yên. Nhưng xen kẽ không phải là chạy cùng lúc: một phép tính toán nặng sẽ chiếm luôn luồng đó và làm đơ mọi thứ.
việc CPU nặng CHẶN luồng - không ai chen vào được
function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
console.log("bắt đầu");
console.log(fib(45)); // chạy vài giây tới chục giây (tuỳ máy) - CHIẾM luồng suốt thời gian đó
console.log("xong"); // phải đợi fib() xong mới in
// Trong lúc fib() chạy: click, request HTTP, setTimeout... đều bị treo. - ▸JavaScript là ngôn ngữ đơn luồng: một luồng chạy code của bạn tại một thời điểm.
- ▸Event loop cho “đồng thời” (concurrency) khi CHỜ I/O - không phải “song song” (parallelism).
- ▸Việc CPU-bound nặng chặn cả luồng: trang web đơ, server ngừng nhận request.
Trung thực: async ≠ song song
Không phải việc gì cũng cần luồng. Câu hỏi quyết định: việc của bạn đang chờ (I/O-bound) hay đang tính (CPU-bound)?
| Loại việc | Ví dụ | Cách xử lý đúng |
|---|---|---|
| I/O-bound (chờ) | đọc/ghi tệp, gọi mạng, query CSDL | async/await - event loop là đủ, KHÔNG cần worker |
| CPU-bound (tính) | nén ảnh, mã hoá, phân tích dữ liệu lớn | Worker Threads / tiến trình - để dùng nhiều lõi |
- ▸I/O-bound = chủ yếu CHỜ → async/await giải quyết gọn, đừng vẽ rắn thêm chân.
- ▸CPU-bound = chủ yếu TÍNH → mới cần song song để tận dụng nhiều lõi CPU.
- ▸Chẩn đoán sai dẫn tới giải pháp thừa: thêm worker cho việc I/O hầu như vô ích.
Module node:worker_threads cho bạn tạo luồng thật. Đẩy việc nặng sang worker, luồng chính vẫn rảnh để phục vụ người dùng; xong việc, worker postMessage kết quả về.
main.js - luồng chính không bị chặn
import { Worker } from "node:worker_threads";
// "./fib-worker.js" tính theo thư mục bạn ĐỨNG khi chạy node - chạy từ thư mục chứa file
const worker = new Worker("./fib-worker.js", { workerData: 45 });
worker.on("message", (kq) => console.log("kết quả:", kq));
worker.on("error", (err) => console.error(err));
worker.on("exit", (code) => console.log("worker thoát:", code));
console.log("luồng chính chạy tiếp NGAY, không phải đợi worker..."); fib-worker.js - chạy trong luồng riêng
import { parentPort, workerData } from "node:worker_threads";
function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
parentPort.postMessage(fib(workerData)); // gửi kết quả về luồng chính - ▸worker_threads tạo luồng thật, chạy song song trên lõi CPU khác.
- ▸Trao đổi qua message: workerData (gửi đi) và postMessage / on("message") (nhận về).
- ▸Luồng chính không bị chặn → trang/server vẫn phản hồi trong lúc worker tính.
Trung thực: Worker là isolate RIÊNG
Khi cần nhiều luồng cùng đọc/ghi một vùng nhớ (tránh copy dữ liệu lớn), dùng SharedArrayBuffer. Nhưng nhiều luồng ghi cùng lúc sẽ đua nhau - phải dùng Atomics để thao tác an toàn.
vùng nhớ dùng chung + thao tác nguyên tử
const sab = new SharedArrayBuffer(4); // 4 byte dùng chung
const dem = new Int32Array(sab); // nhìn vùng nhớ đó như mảng int32
// Gửi 'sab' cho worker (qua workerData/postMessage):
// CẢ HAI luồng cùng thấy MỘT vùng nhớ - đây là chia sẻ thật, không phải bản sao.
Atomics.add(dem, 0, 1); // tăng an toàn (thao tác nguyên tử - không bị luồng khác xen giữa)
Atomics.load(dem, 0); // đọc an toàn
// dem[0]++ thường: khi 2 luồng cùng chạy có thể MẤT cập nhật → tổng sai. - ▸SharedArrayBuffer = vùng nhớ nhiều luồng cùng thấy (không copy).
- ▸Atomics.add/load/store... đảm bảo thao tác không bị xen giữa → tránh race condition.
- ▸Đổi lại, bạn phải tự lo đồng bộ - chỉ dùng khi thật cần hiệu năng.
Chia 8 việc CPU nặng cho nhiều worker. Kéo số worker lên và quan sát: thời gian co lại, nhưng không tuyến tính mãi - và không bao giờ ngắn hơn việc dài nhất.
Tăng worker thì thời gian co lại - nhưng không bao giờ ngắn hơn việc dài nhất (55), và tốc độ bị chặn trên bởi số worker. Trên máy thật còn một trần nữa: số lõi CPU - thêm worker vượt số lõi gần như vô ích, lại tốn RAM và chi phí giao tiếp. Mô hình này bỏ qua các chi phí đó để làm rõ ý chính.
- ▸Tăng tốc bị CHẶN TRÊN bởi số worker (4 worker → nhanh tối đa ~4×).
- ▸Tổng thời gian hoàn thành (makespan) không bao giờ ngắn hơn việc dài nhất - chia thế nào cũng vậy.
- ▸Trên máy thật còn trần số lõi CPU; vượt số lõi là tốn RAM, không nhanh thêm.
Đôi khi bạn cần hẳn tiến trình riêng - bộ nhớ tách biệt, V8 riêng, một cái sập không kéo cái khác theo. node:child_process tạo tiến trình con; node:cluster nhân bản server ra nhiều tiến trình cùng nghe một cổng.
child_process: fork một tiến trình Node con
import { fork } from "node:child_process";
const con = fork("./viec.js"); // tiến trình Node riêng, bộ nhớ riêng
con.send({ tac_vu: "xu-ly", dau_vao: 42 });
con.on("message", (m) => console.log("con trả lời:", m));
// viec.js:
// process.on("message", (m) => process.send({ ket_qua: nangNhoc(m.dau_vao) })); cluster: chia tải HTTP ra nhiều tiến trình theo số lõi
import cluster from "node:cluster";
import { createServer } from "node:http";
import { availableParallelism } from "node:os";
if (cluster.isPrimary) {
for (let i = 0; i < availableParallelism(); i++) cluster.fork(); // mỗi lõi một con
} else {
// Các con cùng .listen(3000): tiến trình CHÍNH nhận kết nối rồi chia
// vòng tròn (round-robin) cho các con - mặc định trên Linux/macOS.
createServer((req, res) => res.end(`Chào từ PID ${process.pid}`)).listen(3000);
} | Worker Threads | Tiến trình (child_process/cluster) | |
|---|---|---|
| Bộ nhớ | cùng tiến trình; chia sẻ được qua SharedArrayBuffer | tách biệt hoàn toàn; chỉ qua message |
| Chi phí | nhẹ hơn | nặng hơn (mỗi tiến trình một V8) |
| Một cái sập | có thể ảnh hưởng tiến trình chung | cách ly - an toàn hơn |
| Hợp cho | tính toán nặng, cần chia sẻ bộ nhớ | scale server (cluster), cách ly/an toàn |
- ▸child_process: tiến trình Node riêng, trao đổi qua send/on("message").
- ▸cluster: nhiều tiến trình cùng nghe một cổng - cách kinh điển để scale HTTP server đa lõi.
- ▸Tiến trình tách biệt = an toàn hơn khi sập, nhưng tốn RAM và không chia sẻ bộ nhớ trực tiếp.
Trình duyệt cũng có “worker”: Web Worker - anh em của Worker Threads. Đẩy việc nặng sang đó để giao diện không đơ. Lưu ý: Web Worker không chạm được DOM, chỉ tính toán rồi gửi kết quả về.
trang web + worker.js
// trong trang (luồng chính - luồng vẽ giao diện)
const w = new Worker("./worker.js", { type: "module" });
w.postMessage(45);
w.onmessage = (e) => console.log("kết quả:", e.data); // giao diện KHÔNG đơ
// worker.js - KHÔNG có document/window, chỉ tính toán
onmessage = (e) => {
const kq = nangNhoc(e.data); // nangNhoc = hàm tính nặng của mèo con (tự định nghĩa)
postMessage(kq);
}; - ▸Web Worker giữ cho trang mượt khi có việc tính nặng (xử lý ảnh, dữ liệu lớn).
- ▸Chỉ luồng chính chạm DOM; worker tính xong thì postMessage về để cập nhật giao diện.
- ▸Cùng tư duy với Node: tách việc nặng ra luồng khác, trao đổi qua message.
Gói lại thành một bảng quyết định nhanh:
| Tình huống | Dùng |
|---|---|
| Chờ I/O (mạng, tệp, CSDL) | async/await + Promise.all |
| Một việc CPU nặng, không muốn đơ | một Worker Thread |
| Nhiều việc CPU nặng | worker pool (≈ số lõi) |
| Scale HTTP server đa lõi | cluster |
| Cách ly / an toàn khi sập | child_process |
| Tính nặng trong trình duyệt | Web Worker |
- ▸Tạo worker tốn chi phí: dùng POOL tái sử dụng, đừng tạo/huỷ liên tục.
- ▸Truyền dữ liệu lớn qua message tốn copy: cân nhắc transferable hoặc SharedArrayBuffer.
- ▸Nhiều worker hơn số lõi CPU → không nhanh thêm, còn tốn RAM.
- ▸Mặc định nên ưu tiên async; chỉ thêm luồng/tiến trình khi đo được nút thắt là CPU.
Tiếp theo
Câu hỏi thường gặp
Nhờ event loop + I/O bất đồng bộ, KHÔNG phải nhờ nhiều luồng JavaScript. Khi một request chờ đọc tệp hay query CSDL, Node không đứng yên - nó nhận request khác. Việc chờ I/O do hệ điều hành (và một threadpool nhỏ của libuv cho fs/dns) lo, còn code JS của bạn vẫn chạy trên đúng một luồng. Đó là “đồng thời” (concurrency) chứ chưa phải “song song” (parallelism).
Không, nếu là tính toán JS thuần. Trên cùng một luồng, async chỉ XEN KẼ các khoảng CHỜ I/O. Promise.all() khởi động nhiều tác vụ I/O “cùng lúc” (chúng chờ song song), nhưng phần tính toán bằng JavaScript vẫn lần lượt trên một luồng. Muốn tính toán THẬT SỰ song song trên nhiều lõi, bạn cần Worker Threads hoặc nhiều tiến trình.
Không. Khác với thread trong C/Java, mỗi Worker là một isolate V8 RIÊNG - không nhìn thấy biến của luồng khác. Mặc định, dữ liệu gửi qua postMessage là một BẢN SAO (structured clone). Muốn cùng đọc/ghi một vùng nhớ thì phải dùng SharedArrayBuffer + Atomics (và tự lo chuyện đồng bộ).
Worker Threads = nhiều luồng TRONG cùng một tiến trình: nhẹ hơn, có thể chia sẻ bộ nhớ qua SharedArrayBuffer. child_process / cluster = nhiều TIẾN TRÌNH Node tách biệt: mỗi cái có bộ nhớ và V8 riêng, an toàn hơn khi một cái sập, nhưng tốn RAM hơn và chỉ trao đổi qua message. cluster chuyên để cho nhiều tiến trình cùng nghe một cổng và chia tải một HTTP server.
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
Server Node của mèo con chậm vì mỗi request phải đợi một câu query cơ sở dữ liệu (I/O) khá lâu. Thêm Worker Threads có giúp không?
- 1
Cảm nhận “đơ luồng”
Mèo con viết hàm
fib(n)đệ quy, gọifib(45)đồng bộ, ngay sau đóconsole.log("xong"). Đặt thêm mộtsetIntervalin mỗi 100ms trước khi gọi.Hoàn thành khi: Trong lúc
fib(45)chạy,setIntervalKHÔNG in được dòng nào - chứng tỏ CPU-bound chặn cả event loop. - 2
Worker đầu tiên
Chuyển
fib(45)sang một file worker dùngnode:worker_threads; luồng chính vẫn chạysetIntervalbình thường và nhận kết quả qua message.Hoàn thành khi: Luồng chính in đều mỗi 100ms trong khi worker tính; kết quả
fibtrả về quaworker.on("message"). - 3
Đếm lõi CPU
In
os.availableParallelism()trong Node vànavigator.hardwareConcurrencytrong Console trình duyệt.Hoàn thành khi: Ghi lại hai con số - đó là trần tăng tốc thực tế trên máy của mèo con.
- 4
Đo tăng tốc của pool
Chia 8 việc CPU nặng cho 1, 2, rồi 4 worker; đo thời gian mỗi lần và so với mô phỏng ở Bước 5.
Hoàn thành khi: Thấy thời gian giảm khi thêm worker nhưng bão hoà quanh số lõi - đúng như mô hình.
- 5
cluster cho server
Dựng một HTTP server bằng
node:cluster, fork theo số lõi, mỗi response inprocess.pid. Gọi nhiều request liên tiếp.Hoàn thành khi: Quan sát nhiều PID khác nhau cùng phục vụ - tải được chia ra nhiều tiến trình.
- 6
Race condition & Atomics
Hai worker cùng tăng một
Int32ArraytrênSharedArrayBuffer1_000_000 lần mỗi worker. Làm một lần bằngarr[0]++thường, một lần bằngAtomics.add.Hoàn thành khi: Bản thường cho tổng SAI (mất cập nhật; hai worker phải chạy TRÙNG thời điểm mới đua - nếu ra đúng, chạy lại vài lần); bản
Atomicsluôn cho đúng 2000000.