worker_threads 的真正威力不仅在于它能并行执行 CPU 密集型任务,更在于它提供了高效且安全的线程间数据交换机制。Node.js 为工作线程准备了两种主要的通信路径:基于消息端口的显式消息传递,以及基于共享内存的直接数据共享。两者在适用场景和性能特点上有明显差异。
1. 消息传递:postMessage 与 MessagePort
每个工作线程都拥有一个 parentPort(主线程侧的对应端口是 worker.postMessage)。主线程和 Worker 可以像下面这样互发消息:
主线程(main.js)
const { Worker } = require('worker_threads');
const worker = new Worker('./worker.js');
worker.on('message', (msg) => {
console.log('主线程收到:', msg);
});
worker.postMessage({ task: 'compute', params: [1, 2] });
工作线程(worker.js)
const { parentPort } = require('worker_threads');
parentPort.on('message', (msg) => {
console.log('Worker 收到:', msg);
// 执行任务后回复
parentPort.postMessage({ result: msg.params.reduce((a, b) => a + b, 0) });
});
通过 postMessage 发送的对象会经过结构化克隆(structured clone)算法进行深拷贝。这保证了发送方和接收方持有的对象完全独立,不会意外相互影响。你可以像复制普通对象一样传递多数内置类型(Object、Array、Map、Set、Date、TypedArray 等),但函数、DOM 对象、WeakMap 等无法被克隆。
2. 高性能传输:利用 Transferable 对象转移所有权
当需要传递大块二进制数据(如图像、文件缓冲区)时,深拷贝会带来不必要的内存和 CPU 开销。Node.js 允许将 ArrayBuffer 或 TypedArray 作为 可转移对象(Transferable)传递。此时,内存的所有权会直接从发送方转移到接收方,发送方将无法再访问该数据,避免了复制过程。
转移缓冲区的例子:
// 主线程
const buf = Buffer.alloc(1024 * 1024); // 1 MB
buf.fill(1);
worker.postMessage(buf, [buf.buffer]); // 转移底层 ArrayBuffer
// Worker 接收
parentPort.on('message', (msg) => {
// msg 现在是同一个 Buffer,但所有权已转移,原 buf 在主线程失效
console.log(msg[0]); // 1
});
第二个参数 [buf.buffer] 是转移列表,告诉引擎将 buf.buffer 的所有权交给 Worker。这种机制在音视频处理、加密解密、大数据交换场景中能显著提升性能。
3. 共享内存:SharedArrayBuffer 与 Atomics
消息传递是“传数据”的方式,共享内存则是“共享数据”的方式。SharedArrayBuffer 允许多个线程直接读写同一块内存区域,配合 Atomics 对象可以进行原子操作,确保数据一致性——无需任何锁。
共享数组的使用:
// 主线程
const { Worker } = require('worker_threads');
const sharedBuffer = new SharedArrayBuffer(4); // 4 字节
const sharedArray = new Int32Array(sharedBuffer);
sharedArray[0] = 0;
const worker = new Worker('./worker.js', {
workerData: sharedBuffer
});
worker.on('message', () => {
console.log('最终值:', sharedArray[0]);
});
worker.js
const { parentPort, workerData } = require('worker_threads');
const sharedArray = new Int32Array(workerData);
// 原子操作,不必担心竞态
Atomics.add(sharedArray, 0, 1); // 等价于 sharedArray[0] += 1,但是原子的
parentPort.postMessage('done');
原子操作包括 add、sub、and、or、xor、compareExchange 等。当多个 Worker 同时修改同一位置时,这些操作保证不会出现读-修改-写的中断问题。
线程等待与唤醒:Atomics.wait() 和 Atomics.notify() 提供轻量级的同步原语。Worker A 可以进入等待状态,直到另一个线程通知它继续。注意,为了避免死锁,Atomics.wait 不能在 Node.js 的主线程中使用(主线程不支持),只能在 Worker 中使用,主线程更适合用异步消息协调。
4. 实际使用中的注意事项
- 结构化克隆的边界:如果你尝试传递一个含有无法克隆对象的复杂图(如文件描述符、WritableStream),
postMessage会抛出异常。必要时将数据转换为 JSON 或可序列化格式再发送。 - 共享内存的安全风险:
SharedArrayBuffer在浏览器环境中受限于跨域隔离策略,在 Node.js 中虽然可以自由使用,但不当的并发写入(不使用Atomics)会引发难以调试的竞态条件。永远使用Atomics进行写入操作。 - 内存生命周期:共享内存不会被 V8 的垃圾回收自动释放——它一直存在,直到所有引用它的线程都断开连接。要小心内存泄漏,及时终止 Worker 或主动释放引用。
- 与
cluster的取舍:worker_threads因为共享内存和轻量特性,更适合 CPU 密集型并行计算(如排序、图像处理、复杂数学运算)。而cluster多进程是独立进程,内存隔离,但通信成本更高,更适合 I/O 密集型 Web 服务的多核负载均衡。
线程间通信和共享内存是 worker_threads 模块的核心能力,它们使得 Node.js 在面对计算密集型任务时不再束手束脚。理解消息传递的复制/转移机制,以及共享内存的原子化操作,可以帮助你在需要并行计算的场景中写出既安全又高效的后端代码。