← Lập trình JavaScript nâng cao

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

async/await không tạo thêm luồng nào. Nó chỉ giúp bạn viết gọn việc chờ (mạng, tệp). Hai phép tính JavaScript không bao giờ chạy cùng lúc trên một luồng. Muốn song song thật - dùng nhiều lõi cùng lúc - bạn cần Worker Threads hoặc nhiều tiến trình.

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ệcVí dụCách xử lý đúng
I/O-bound (chờ)đọc/ghi tệp, gọi mạng, query CSDLasync/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ớnWorker 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

Khác thread trong C/Java, hai Worker không chia sẻ biến - mỗi cái là một thế giới V8 riêng. Dữ liệu gửi qua postMessage là một bản sao (structured clone), không phải tham chiếu. Điều này giúp tránh phần lớn lỗi “đua” (race condition), nhưng copy dữ liệu lớn thì tốn - xem Bước 4.

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.

Số worker (luồng):
W1
40
25
55
30
50
20
45
35
 300
300
thời gian chạy
1.00×
nhanh hơn 1 luồng
300
nếu chạy 1 luồng

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 ThreadsTiến trình (child_process/cluster)
Bộ nhớcùng tiến trình; chia sẻ được qua SharedArrayBuffertách biệt hoàn toàn; chỉ qua message
Chi phínhẹ hơnnặng hơn (mỗi tiến trình một V8)
Một cái sậpcó thể ảnh hưởng tiến trình chungcách ly - an toàn hơn
Hợp chotí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ốngDù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ặngworker pool (≈ số lõi)
Scale HTTP server đa lõicluster
Cách ly / an toàn khi sậpchild_process
Tính nặng trong trình duyệtWeb 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

Bạn đã có bức tranh đầy đủ về đồng thời và song song trong JS - mảnh ghép “bản chất” tiếp theo, sau event loop & libuv ở bài 1. Bài kế - Process, môi trường & công cụ CLI - nhìn kỹ chính tiến trình Node mà bạn vừa fork: argv, env, tín hiệu, exit code; rồi mọi mảng (Stream, mạng, cơ sở dữ liệu) ghép lại trong dự án backend cuối khoá.

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.

Cỡ số lõi CPU là vừa - đọc bằng os.availableParallelism() (Node) hoặc navigator.hardwareConcurrency (trình duyệt). Tạo nhiều hơn số lõi gần như không nhanh thêm, lại tốn RAM và chi phí giao tiếp. Và đừng tạo/huỷ worker liên tục: hãy dùng một “pool” worker tái sử dụng.

Không. Chỉ luồng chính mới chạm được DOM. Web Worker dùng để TÍNH cái nặng (xử lý ảnh, phân tích dữ liệu) mà không làm đơ trang, rồi postMessage kết quả về cho luồng chính cập nhật giao diện.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

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. 1

    Cảm nhận “đơ luồng”

    Mèo con viết hàm fib(n) đệ quy, gọi fib(45) đồng bộ, ngay sau đó console.log("xong"). Đặt thêm một setInterval in mỗi 100ms trước khi gọi.

    Hoàn thành khi: Trong lúc fib(45) chạy, setInterval KHÔNG in được dòng nào - chứng tỏ CPU-bound chặn cả event loop.

  2. 2

    Worker đầu tiên

    Chuyển fib(45) sang một file worker dùng node:worker_threads; luồng chính vẫn chạy setInterval bì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ả fib trả về qua worker.on("message").

  3. 3

    Đếm lõi CPU

    In os.availableParallelism() trong Node và navigator.hardwareConcurrency trong 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. 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. 5

    cluster cho server

    Dựng một HTTP server bằng node:cluster, fork theo số lõi, mỗi response in process.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. 6

    Race condition & Atomics

    Hai worker cùng tăng một Int32Array trên SharedArrayBuffer 1_000_000 lần mỗi worker. Làm một lần bằng arr[0]++ thường, một lần bằng Atomics.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 Atomics luôn cho đúng 2000000.