Cái bẫy Đơn luồng: Tại sao Worker Threads lại quan trọng
Node.js nổi tiếng hiệu quả với các tác vụ I/O-bound, chẳng hạn như xử lý hàng nghìn truy vấn cơ sở dữ liệu đồng thời. Tuy nhiên, kiến trúc Event Loop đơn luồng của nó có một điểm yếu chí mạng: các hoạt động tiêu tốn nhiều CPU. Nếu bạn chạy một tác vụ mất 500ms để hoàn thành một cách đồng bộ, toàn bộ máy chủ của bạn sẽ ngừng phản hồi mọi người dùng khác trong nửa giây đó. Trong môi trường lưu lượng cao, điều này khiến độ trễ p99 tăng vọt và có thể gây ra các lỗi dây chuyền.
Mọi thứ sẽ dừng lại khi Event Loop bị chặn. Các kết nối TCP mới bị bỏ qua và các callback của setTimeout đã lên lịch bị trì hoãn. Gần đây, tôi đã chẩn đoán một vấn đề trong môi trường production, nơi một thao tác phân tích (parse) JSON của tệp 50MB đã chặn luồng trong 1,2 giây, khiến các lần kiểm tra sức khỏe (health checks) thất bại và kích hoạt việc khởi động lại container không cần thiết. Làm chủ Worker Threads không chỉ là tăng cường hiệu suất—đó là yêu cầu về sự ổn định.
Trước khi có module worker_threads, các nhà phát triển thường sử dụng cluster hoặc child_process. Chúng vẫn hoạt động, nhưng khá nặng nề. Mỗi tiến trình con yêu cầu một instance bộ nhớ riêng, thường tiêu tốn 30MB hoặc hơn chỉ để khởi động. Worker Threads (được giới thiệu trong Node.js 10.5.0) giải quyết vấn đề này bằng cách chạy nhiều môi trường JavaScript trong cùng một tiến trình, cho phép chia sẻ bộ nhớ hiệu quả.
Thiết lập môi trường
Module worker_threads được tích hợp sẵn trong nhân Node.js. Mặc dù nó đã có từ phiên bản 10, tôi khuyên bạn nên sử dụng Node.js v18 hoặc v20+ để tận dụng thời gian khởi động nhanh hơn và hỗ trợ ESM tốt hơn. Kiểm tra môi trường của bạn bằng một lệnh nhanh:
node -v
Các worker thô rất mạnh mẽ, nhưng quản lý chúng thủ công trong môi trường production là một rủi ro. Việc tạo một luồng mới cho mỗi yêu cầu tốn khoảng 10–15ms chi phí vận hành (overhead) và tiêu tốn khoảng 20MB RAM. Để giảm thiểu điều này, hãy sử dụng một thư viện thread pool như piscina. Nó quản lý một hàng đợi các tác vụ và giữ cho các worker luôn “ấm” (warm), hiệu quả hơn nhiều so với việc khởi tạo chúng liên tục.
npm install piscina
Cấu hình Worker để mở rộng
Quản lý luồng hiệu quả đòi hỏi sự tách biệt rõ ràng giữa các trách nhiệm. Worker nên là một script chuyên biệt chỉ làm một việc duy nhất: tính toán.
1. Worker Script
Tạo tệp processor-worker.js. Script này nhận dữ liệu, thực hiện các tác vụ nặng và gửi kết quả trở lại.
const { parentPort, workerData } = require('worker_threads');
// Ví dụ thực tế: Chuyển đổi dữ liệu nặng hoặc băm Bcrypt
function processData(data) {
let count = 0;
for (let i = 0; i < data.limit; i++) {
count += Math.sqrt(i);
}
return count;
}
const result = processData(workerData);
parentPort.postMessage(result);
2. Tích hợp vào Luồng chính
Ứng dụng chính của bạn phải quản lý vòng đời của worker. Việc bao bọc worker trong một Promise giúp nó tương thích với các mô hình async/await hiện đại, giữ cho mã nguồn của bạn sạch sẽ.
const { Worker } = require('worker_threads');
function spawnWorker(data) {
return new Promise((resolve, reject) => {
const worker = new Worker('./processor-worker.js', { workerData: data });
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) reject(new Error(`Worker thất bại với mã thoát (exit code) ${code}`));
});
});
}
async function handleHeavyRequest(req, res) {
try {
// Đẩy vòng lặp 100 triệu lần lặp sang một luồng nền
const result = await spawnWorker({ limit: 100000000 });
res.send({ status: 'completed', result });
} catch (err) {
res.status(500).send({ error: 'Xử lý thất bại' });
}
}
3. Tối ưu hóa với SharedArrayBuffer
Việc truyền các đối tượng lớn giữa các luồng thường sử dụng Thuật toán Structured Clone, vốn thực hiện sao chép dữ liệu. Với một buffer 10MB, thao tác sao chép này có thể mất vài mili giây. SharedArrayBuffer cho phép các luồng ánh xạ vào cùng một vùng nhớ vật lý, loại bỏ hoàn toàn chi phí sao chép. Hãy sử dụng tính năng này khi xử lý các buffer ảnh lớn hoặc các tập dữ liệu phân tích.
// Trong luồng chính, cấp phát 1MB bộ nhớ dùng chung
const sharedBuffer = new SharedArrayBuffer(1024 * 1024);
const worker = new Worker('./worker.js', { workerData: { buffer: sharedBuffer } });
Giám sát & Hàng rào bảo vệ trong Production
Triển khai mới chỉ là một nửa chặng đường. Bạn phải giám sát cách các luồng này hoạt động dưới tải trọng để đảm bảo chúng không chiếm dụng hết tài nguyên hệ thống.
1. Đo lường độ trễ (Lag)
Theo dõi sức khỏe của luồng chính bằng cách đo độ trễ của Event Loop. Nếu các worker của bạn được cấu hình đúng, độ trễ này sẽ duy trì dưới 10–20ms ngay cả khi tải nặng. Sử dụng module perf_hooks có sẵn để thu thập các số liệu này:
const { monitorEventLoopDelay } = require('perf_hooks');
const histogram = monitorEventLoopDelay();
histogram.enable();
setInterval(() => {
console.log(`Độ trễ Event Loop p99: ${histogram.percentile(99) / 1e6}ms`);
}, 5000);
2. Triển khai Timeout
Worker có thể bị treo hoặc rơi vào vòng lặp vô hạn giống như luồng chính. Đừng bao giờ để một worker chạy vô thời hạn. Nếu một tác vụ thông thường mất 200ms mà vẫn chưa kết thúc sau 2 giây, chắc chắn có vấn đề gì đó. Hãy gọi worker.terminate() để đóng luồng và giải phóng nhân CPU. Cơ chế dự phòng này giúp ngăn một tác vụ lỗi làm giảm hiệu suất của toàn bộ máy chủ.
3. Phù hợp với số lượng nhân CPU
Đừng vượt quá khả năng của CPU. Nếu máy chủ của bạn có 4 nhân, việc chạy 10 worker cùng lúc sẽ gây ra chi phí chuyển đổi ngữ cảnh (context-switching) làm chậm mọi thứ. Một quy tắc ngón tay cái tốt là đặt kích thước thread pool thành os.cpus().length - 1, để lại một nhân dành riêng cho Event Loop chính và các tác vụ I/O.
Worker Threads là một công cụ đặc thù. Hãy sử dụng chúng cho các phép toán nặng, thay đổi kích thước hình ảnh hoặc tạo PDF phức tạp. Đối với các logic API tiêu chuẩn, hãy tuân theo các mô hình non-blocking mặc định của Node. Bằng cách đẩy chỉ 5% phần mã nặng nhất sang worker, bạn có thể tăng thông suất lên gấp 10 lần mà không cần thay đổi phần cứng bên dưới.

