前面章节介绍的 cluster 模块通过多进程在多核 CPU 上扩展了 Node.js 的并发能力,但进程之间的隔离性既是优势也是局限——它们无法直接共享内存,数据交换需要经过序列化与 IPC 通道。对于某些 CPU 密集型任务(如图像处理、复杂加密、大数据解析),如果能在更轻量的线程中运行,共享一部分内存并且启动更快,会明显提升性能。从 Node.js v10.5.0 开始以实验性形式引入、在 v12 中正式稳定的 worker_threads 模块,正是为此而生。
10.4.1 为什么需要工作线程
Node.js 主线程是单线程的,擅长处理 I/O 并发。但如果执行长时间的 CPU 计算(例如遍历 10 万个大对象的 JSON 解析、计算斐波那契数列、执行正则表达式),主线程会被持续占用,事件循环中的 I/O 回调全部延迟,用户感受到的就是请求超时、服务假死。
将这部分计算“挪出主线程”的思路有两种:
cluster多进程:每个进程有独立的 V8 实例和内存空间,完全隔离,启动相对较慢,进程间通信有序列化开销。worker_threads工作线程:在同一进程内创建多个 JavaScript 线程,它们共享内存空间(通过SharedArrayBuffer),可以高效地处理数据;同时又是轻量级的,启动速度远快于进程。
10.4.2 基本概念
在 worker_threads 中,每一个工作线程都是一个独立的 JavaScript 执行环境,拥有自己的 V8 实例(独立的堆、调用栈、事件循环),但它们与主线程以及彼此之间可以通过消息传递和共享内存进行交流。
Worker类:用于创建工作线程。workerData:向工作线程传递初始数据的只读副本。parentPort:在工作线程中访问,用于与父线程双向通信。MessageChannel/MessagePort:支持更灵活的多对多通信。
工作线程是真正的操作系统线程(而非“绿色线程”),由 libuv 的线程池管理和调度,可以利用多核 CPU 并行计算。但与浏览器 Web Worker 不同,Node.js 的工作线程可以访问部分 Node.js API(如 require、fs、Buffer 等),能力更强。
10.4.3 创建并使用工作线程
最基础的例子:主线程创建一个 Worker,并接收它返回的结果。
主线程文件 main.js:
const { Worker } = require('worker_threads');
const worker = new Worker('./worker.js', {
workerData: { start: 1, end: 1000000 }
});
worker.on('message', (result) => {
console.log(`计算结果:${result}`);
});
worker.on('error', (err) => {
console.error('工作线程错误:', err);
});
worker.on('exit', (code) => {
if (code !== 0) {
console.error(`工作线程异常退出,退出码:${code}`);
}
});
工作线程文件 worker.js:
const { parentPort, workerData } = require('worker_threads');
function heavyComputation(start, end) {
let sum = 0;
for (let i = start; i <= end; i++) {
sum += i;
}
return sum;
}
const result = heavyComputation(workerData.start, workerData.end);
// 向父线程发送结果
parentPort.postMessage(result);
workerData 通过结构化克隆(structured clone)传递给工作线程,因此可以传递对象、数组等普通数据类型,但不能传递函数、Symbol 或包含循环引用的对象。
10.4.4 线程间双向通信
除了工作线程计算结果后单次发送外,也可以实现持续的双向通信。
主线程保持不变,worker.js 改为:
const { parentPort } = require('worker_threads');
parentPort.on('message', (tasks) => {
// 处理分配的任务
const results = tasks.map(task => {
// CPU 密集操作...
return task * 2;
});
// 发送回结果
parentPort.postMessage(results);
});
主线程可以反复向 Worker 发送消息:
worker.postMessage([1, 2, 3, 4]);
worker.on('message', (res) => {
console.log('收到结果:', res); // [2, 4, 6, 8]
});
如果需要多个工作线程之间互相通信,可以使用 MessageChannel:
const { Worker, MessageChannel } = require('worker_threads');
const { port1, port2 } = new MessageChannel();
const worker1 = new Worker('./worker1.js', { workerData: { port: port1 } });
const worker2 = new Worker('./worker2.js', { workerData: { port: port2 } });
// 将 port 传递给 worker,worker 内部可监听 port 上的消息
10.4.5 共享内存和原子操作
消息传递涉及数据克隆,如果数据量极大(如上 GB 的图片或缓冲区),复制开销会很高。Node.js 提供了 SharedArrayBuffer 允许主线程与工作线程共享同一块内存,配合 Atomics 进行同步操作,避免竞态条件。
典型场景:多个线程并行更新同一个计数器。
主线程:
const { Worker } = require('worker_threads');
// 创建一个 4 字节的共享缓冲区,初始化为 0
const sharedBuffer = new SharedArrayBuffer(4);
const sharedArray = new Int32Array(sharedBuffer);
const worker1 = new Worker('./worker.js', { workerData: { sharedArray } });
const worker2 = new Worker('./worker.js', { workerData: { sharedArray } });
Promise.all([
new Promise(resolve => worker1.on('exit', resolve)),
new Promise(resolve => worker2.on('exit', resolve))
]).then(() => {
console.log('最终计数:', Atomics.load(sharedArray, 0));
});
工作线程:
const { parentPort, workerData } = require('worker_threads');
const { sharedArray } = workerData;
// 原子增加操作
for (let i = 0; i < 100000; i++) {
Atomics.add(sharedArray, 0, 1);
}
Atomics.add 保证即使多个线程同时修改,最终结果也是正确的。共享内存省去了序列化成本,但编程模型更复杂,需谨慎处理同步问题。
10.4.6 线程池与最佳实践
像上面的例子手动管理 Worker 生命周期很快就会变得繁琐。实际项目中通常使用线程池库来复用线程,例如 Piscina(Node.js 官方推荐的池化实现):
const Piscina = require('piscina');
const pool = new Piscina({
filename: './worker.js',
maxThreads: 4,
minThreads: 2
});
(async () => {
const result = await pool.run({ start: 1, end: 100000 });
console.log('结果:', result);
})();
Piscina 会自动创建工作线程池,排队任务,还可以设置任务超时、取消等。这种方式避免了频繁创建销毁线程的开销,也合理利用了多核资源。
10.4.7 worker_threads 与 cluster 的对比
| 维度 | worker_threads | cluster |
|--------------|--------------------------------------|-------------------------------|
| 进程/线程 | 同一进程内的线程,共享内存空间 | 独立进程,内存隔离 |
| 内存开销 | 轻量,启动快,共享内存可直接读写 | 较重,每个进程有独立 V8 堆 |
| 通信机制 | postMessage(克隆/转移)、SharedArrayBuffer | IPC 通道(序列化) |
| 适用场景 | CPU 密集型任务:计算、解析、压缩 | I/O 密集型:HTTP 服务器并发 |
| 稳定性 | 一个线程崩溃可能影响整个进程 | 进程隔离,单个崩溃不影响其他 |
两者并非互斥,可以根据任务性质组合使用。例如用 cluster 启动多个 HTTP 服务进程,每个进程内再使用 worker_threads 处理上传的图片压缩,从而同时获得多进程的负载均衡能力和多线程的共享内存并行计算能力。
10.4.8 注意事项
- 不能共享函数:
postMessage只传输可序列化的数据,函数和循环引用会报错。 - 资源清理:Worker 使用完毕后应主动终止(
worker.terminate()),避免线程泄露。 - 错误处理:工作线程内未捕获的异常会触发
error事件并可能导致线程退出,需要在error事件中妥善记录或重启。 - 不要滥用:线程数量应与 CPU 核心数匹配,过多线程只会增加上下文切换开销,降低整体性能。通常
os.cpus().length是一个合理的上限。 - Node.js API 受限:虽然工作线程可以使用部分 Node.js 核心模块,但某些涉及进程级别状态的 API(如
process.chdir())可能产生未定义行为,需查阅文档。
10.4.9 小结
worker_threads 填补了 Node.js 在 CPU 密集型并行处理上的短板,同时保留了消息传递的易用性和共享内存的高效性。它将 JavaScript 的并发模型从“单线程 + 进程集群”扩展到了真正的多线程领域,使得开发者能够更精细化地利用多核 CPU,构建高性能应用。结合 cluster 模块,可以形成一个灵活的“进程管理 I/O 并发 + 线程处理 CPU 计算”的双层架构,几乎没有软肋地覆盖现代服务端场景。