Node.js chạy JavaScript trên một luồng duy nhất. Nghe thì gọn, nhưng tới lúc phải xử lý mấy việc nặng — nén ảnh, parse file CSV vài trăm MB, hay băm mật khẩu — cả server đứng im. Request của người khác phải xếp hàng chờ, dù chẳng liên quan gì tới việc đang chạy.
Bài này mình nói về worker_threads: khi nào đáng dùng, khi nào đừng, và mấy cái bẫy mình từng dính.
Vì sao một hàm nặng lại chặn cả server
Event loop xử lý từng callback một. Khi code chạy đồng bộ — không await, không nhường quyền — event loop không làm được gì khác cho tới khi xong. Một vòng lặp 5 giây nghĩa là 5 giây mọi request khác bị treo.
// Đừng đặt cái này trong request handler
function hashPasswordSync(password: string): string {
return crypto.scryptSync(password, 'salt', 64).toString('hex');
}
scryptSync tốn CPU đúng như thiết kế — mục đích của nó là chậm. Nhưng đặt trong handler thì mình vừa tự khoá cửa. Lúc chạy máy dev một mình thì không thấy gì, lên production có 50 request cùng lúc mới lộ.
Worker thread khác gì so với mấy cách còn lại
Ba cách hay bị đem ra so:
child_process— tiến trình riêng, cách ly tốt nhất, nhưng nặng và giao tiếp qua IPC khá chậm.cluster— nhiều tiến trình dùng chung cổng, hợp để tận dụng nhiều CPU cho HTTP. Mỗi tiến trình có RAM riêng, không chia sẻ state.worker_threads— luồng thật trong cùng tiến trình, có event loop và heap riêng, khởi động nhẹ hơn nhiều và chuyển dữ liệu lớn gần như miễn phí quaArrayBuffertransfer.
Quy tắc mình hay dùng: việc tốn CPU → worker thread. Việc tốn I/O (gọi DB, gọi API) → async bình thường là đủ, đừng đụng tới worker. Việc cần cách ly hoàn toàn, sợ crash kéo theo → tiến trình riêng.
Ví dụ: đẩy việc băm mật khẩu ra luồng riêng
Tách phần nặng vào một file worker:
// hash.worker.ts
import { parentPort, workerData } from 'node:worker_threads';
import { scryptSync } from 'node:crypto';
const { password } = workerData as { password: string };
parentPort!.postMessage(scryptSync(password, 'salt', 64).toString('hex'));
Rồi gọi từ main thread. Chỗ này mình dùng piscina vì nó lo sẵn hàng đợi worker — tự viết pool cũng được nhưng dễ sai phần tái sử dụng:
// server.ts
import Piscina from 'piscina';
const pool = new Piscina({
filename: new URL('./hash.worker.js', import.meta.url).href,
maxThreads: 4,
});
app.post('/login', async (req, res) => {
const hash = await pool.run({ password: req.body.password });
res.json({ hash });
});
Giờ 4 request cùng lúc được chia cho 4 worker, còn event loop vẫn rảnh để nhận request mới. Response đầu tiên về nhanh hơn hẳn vì không phải chờ ai.
Mấy cái bẫy mình từng dính
Đừng spawn worker mỗi request. Khởi động một worker tốn vài chục mili-giây. Nếu mỗi request tạo một cái rồi để nó chết, bạn vừa có overhead của worker, vừa có overhead của việc tạo luồng — chậm hơn cả làm đồng bộ. Luôn dùng pool.
Truyền dữ liệu lớn đúng cách. Mặc định worker nhận dữ liệu qua structured clone, một object 50MB sẽ bị copy nguyên xi. Nếu là ArrayBuffer, truyền kèm transferList để chuyển quyền sở hữu thay vì copy:
worker.postMessage(buffer, [buffer]); // buffer gốc bị vô hiệu, worker nhận bản mới
Luôn bắt lỗi trong worker. Một worker chết âm thầm sẽ khiến await treo tới lúc timeout. Gắn worker.on('error', ...) hoặc để piscina ném lỗi ra promise.
Không chia sẻ state mutable. Mỗi worker có heap riêng. Biến trong main thread không tồn tại bên worker. Cần chia sẻ thật thì dùng SharedArrayBuffer, còn lại nên truyền dữ liệu qua lại.
Đo trước khi tối ưu. Node có --prof và perf_hooks. Nhiều khi "chỗ chậm" không phải chỗ mình nghĩ — mình từng đổ công cho một hàm băm trong khi thủ phạm thật là một JSON.parse trên payload 8MB.
Kết
worker_threads không phải viên đạn bạc, và cũng không nên là thứ đầu tiên bạn với tới. Nó chỉ đáng khi thật sự có phần việc tốn CPU đang bóp nghẹt event loop. Còn lại, async đúng cách đã lo được phần lớn. Nhưng khi cần thì nó là khác biệt giữa một API trả lời 50ms và một API đứng im vì một người dùng đang upload file.
📋 Phụ lục thuật ngữ
| Thuật ngữ | Giải thích ngắn |
|---|---|
| Event loop | Cơ chế chạy callback của Node, xử lý tuần tự từng việc một trên luồng chính |
| worker_threads | Module luồng thật trong Node, có event loop và heap riêng |
| Structured clone | Cách copy dữ liệu giữa các luồng, không giữ lại hàm hay tham chiếu |
| transferList | Danh sách ArrayBuffer được chuyển quyền sở hữu thay vì copy |
| SharedArrayBuffer | Vùng nhớ chia sẻ giữa nhiều worker, dùng cho dữ liệu số |
| CPU-bound | Việc tốn CPU, chạy càng lâu càng chiếm thời gian của luồng |
| I/O-bound | Việc chờ mạng, ổ đĩa, DB — async xử lý tốt, không cần worker |