人人都会AI编程

多线程处理 CPU 密集型任务

更新时间:2026-07-10

Node.js 的单线程事件循环在处理 I/O 密集场景时游刃有余,但一旦遇到 CPU 计算密集的任务(如大量数学运算、图片处理、加解密、复杂正则表达式等),主线程会被长时间占用,导致所有 I/O 回调得不到及时执行,服务出现假死。worker_threads 模块正是为突破这种单线程性能瓶颈而设计的:它允许在独立的线程中执行 JavaScript 和 WebAssembly 代码,将计算任务从主线程剥离,从而保持事件循环的响应性。

为什么需要 Worker 线程

考虑一个简化的场景:Web 服务需要对用户上传的密码进行 bcrypt 哈希处理。bcrypt 的哈希计算会占用大量 CPU 时间,如果直接在请求回调中调用同步版本,处理单个请求就可能阻塞事件循环上百毫秒。在这段时间里,其他请求无法得到响应,并发能力急剧下降。

// 危险:同步哈希会阻塞事件循环
const bcrypt = require('bcrypt');
app.post('/register', (req, res) => {
  const hash = bcrypt.hashSync(req.body.password, 10);
  // 存储 hash...
  res.end('ok');
});

当然,bcrypt 也提供了异步版本,但那只是把计算放在 libuv 的线程池中,依然由主进程内的 C++ 层处理,并不能完全隔离对 JS 单线程的影响。对于更复杂的纯 JavaScript 计算(如处理几百万条数据、运行机器学习推理),必须借助 worker_threads 才能将计算分配到真正的操作系统线程,避免拖垮主线程。

worker_threads 的基本运行模型

worker_threads 模块允许创建多个独立线程,每个线程都有自己的 V8 实例、事件循环和内存隔离。主线程(父线程)可以向 Worker 发送消息,Worker 完成后返回结果,整个过程不会阻塞主线程的事件循环。

  • 创建 Worker:指定一个单独的文件作为线程入口,通过 Worker 构造函数启动。
  • 通信:使用 parentPort(消息端口)在父子线程间传递可序列化的数据,基于结构化克隆算法实现,传递效率较高。
  • 共享内存:使用 SharedArrayBuffer 配合 Atomics 实现线程间的共享内存访问,适合需要低延迟、大数据量交换的场景。
  • 生命周期:Worker 可以通过调用 terminate() 强制终止,也可以在内部通过 parentPort.close() 正常结束。

实际示例:将 CPU 密集计算迁移到 Worker

计算任务: 计算第 n 项斐波那契数(递归实现,模拟 CPU 密集操作)。

主线程文件 main.js

const { Worker } = require('worker_threads');

function runFibWorker(n) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./fib-worker.js', {
      workerData: n         // 向 Worker 传递初始数据
    });
    worker.on('message', resolve);   // 接收 Worker 回传的结果
    worker.on('error', reject);
    worker.on('exit', (code) => {
      if (code !== 0)
        reject(new Error(`Worker 异常退出,退出码 ${code}`));
    });
  });
}

// 模拟 Web 服务
async function handleRequest(n) {
  console.time(`fib(${n})`);
  const result = await runFibWorker(n);
  console.timeEnd(`fib(${n})`);
  console.log(`结果: ${result}`);
  return result;
}

// 并发调用,不会阻塞主线程
handleRequest(40);
handleRequest(41);
console.log('主线程继续执行,不受 Worker 影响');

Worker 文件 fib-worker.js

const { parentPort, workerData } = require('worker_threads');

function fib(n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);
}

const result = fib(workerData);      // 执行计算
parentPort.postMessage(result);      // 将结果发回主线程

运行 main.js 时,主线程瞬间启动两个 Worker,它们各自独立计算斐波那契数,主线程紧接着打印“主线程继续执行”,完全不受阻塞。当 Worker 完成计算后,handleRequest 会收到结果并输出。

线程间通信的高级模式

1. 双向持续通信
parentPort 只是一个单向通道的端点。如果需要长时间运行的 Worker 持续收发消息(如后台数据处理服务),可以监听 parentPort.on('message', callback),并反复 postMessage

2. 使用 MessageChannel
可以通过 MessageChannel 创建一对相互连接的端口,主动分发给不同线程,实现更灵活的通信拓扑。

3. 共享内存与同步原语
对于需要交换大量数据的场景,SharedArrayBuffer 配合 Atomics 可以避免序列化开销:

// 主线程分配共享缓冲区
const { Worker } = require('worker_threads');
const sharedBuffer = new SharedArrayBuffer(1024);
const uint8View = new Uint8Array(sharedBuffer);

const worker = new Worker('./worker.js', { workerData: sharedBuffer });
worker.on('message', () => console.log('Worker 已写入:', uint8View));

// worker.js
const { parentPort, workerData } = require('worker_threads');
const uint8View = new Uint8Array(workerData);
uint8View[0] = 42;                 // 直接修改共享内存
Atomics.store(uint8View, 1, 7);    // 原子操作
parentPort.postMessage('done');

与多进程(cluster)的对比选择

| 维度 | worker_threads(多线程) | cluster(多进程) |
|------|-------------------------|-------------------|
| 内存开销 | 共享进程部分内存(通过 SharedArrayBuffer),资源更省 | 每个进程完全独立内存空间,开销较大 |
| 通信效率 | 结构化克隆或共享内存,极其高效 | 进程间管道/消息队列,有一定序列化成本 |
| 隔离性 | 同一进程内运行,一个线程崩溃可能影响主线程稳定性 | 进程完全隔离,一个进程崩溃不会波及其他 |
| 适用场景 | CPU 密集计算、需要共享复杂数据结构 | Web 服务并发扩容、多核负载均衡 |

简单来说:当你需要同时在多核上分摊 HTTP 请求时,用 cluster 轻量又可靠;当你需要把个别重计算任务从主线程剥离,并且可能需要与主线程共用数据时,用 worker_threads 更合适。两者也可以结合使用:先 fork 多个进程,每个进程内部再放一个 Worker 线程处理计算。

生产环境注意事项

  • 控制线程数量:Worker 线程并非越多越好。每个线程拥有独立的 V8 堆,会带来内存开销;过多的线程切换也会消耗 CPU。通常,CPU 计算任务的最佳线程数接近物理核心数,可通过 os.cpus().length 获取并合理分配。
  • 避免频繁创建销毁:Worker 的创建和销毁成本较高。对于持续传入的任务,采用线程池模型,预先创建固定数量的 Worker,通过消息队列分发任务,复用线程。
  • 监控与异常处理:Worker 内部抛出的未捕获错误会导致线程退出,必须监听 errorexit 事件并及时重建。可使用 uncaughtException 在 Worker 中兜底,但更推荐良好的错误边界。
  • Node.js 版本:Worker 线程在 Node.js 10.5 后可用,12+ 版本稳定。共享内存的 ArrayBuffer 支持与 Atomics 在 10 后已基本完善,但某些高级特性需更高版本。

实战建议

在实际项目中,处理 CPU 密集型任务最常见的模式是建立一个线程池,例如使用第三方库 piscina。它内部封装了 Worker 线程池管理,自动分发任务、收集结果,极大简化了使用:

const Piscina = require('piscina');
const path = require('path');

const pool = new Piscina({
  filename: path.resolve(__dirname, 'fib-worker.js')
});

async function handleRequest(n) {
  const result = await pool.run(n);   // 自动从池中取线程执行
  console.log(result);
}

这种方式既避免了手写线程管理,又获得了稳定的并发性能。

综上所述,worker_threads 是 Node.js 应对 CPU 密集型任务的核心武器。它让单线程模型不再成为计算瓶颈,同时保留了事件驱动的优势。通过在合适场景下引入多线程,你可以构建出既响应迅速又能处理复杂计算的健壮服务。