Node.js 中 worker_threads、child_process 和 cluster 应该如何选择?
简化版
worker_threads 适合在同一进程内开线程处理 CPU 密集 JS 计算;child_process 适合启动独立进程、隔离崩溃或调用外部命令;cluster 适合多进程共享端口提升 Node 服务多核利用率。选择核心看:是否需要共享内存、是否要强隔离、是否是 Web 服务扩容。
详细版
三者都能“让任务离开主事件循环”,但边界不同:
worker_threads:线程级并行,适合 CPU 密集任务,可用ArrayBuffer、SharedArrayBuffer传递或共享数据。child_process:进程级隔离,适合运行脚本、调用系统命令、隔离不可信或容易崩溃的任务。cluster:基于多进程的服务扩容,让多个 worker 进程监听同一端口。
不要用 cluster 解决单个请求里的大计算,也不要用 worker_threads 承担不可信命令执行。前端 BFF 中如果有图片处理、Excel 解析、PDF 生成、大 JSON 计算,应考虑 worker 或独立任务服务。
完整版教学
一、为什么需要把任务移出主线程
Node.js 主线程负责执行 JavaScript 回调和推动事件循环。如果某个请求里做大计算,整个服务都会变慢。多核机器上,只跑一个 Node 主线程也无法充分利用 CPU。
app.get('/report', async (req, res) => {
const data = buildHugeReport(req.query); // CPU 计算 1200ms
res.json(data);
});
如果这段计算耗时 1200ms,期间其他请求即使只是读缓存,也要等主线程让出执行机会。worker、子进程和 cluster 都是在解决“主线程不能被长任务占住”这个问题,但解决角度不同。
CPU 任务 -> worker_threads
外部命令 / 强隔离 -> child_process
Web 服务多核扩容 -> cluster / 多进程管理器
二、worker_threads 适合 CPU 密集 JS
worker_threads 在同一进程内创建工作线程,每个 worker 有自己的 JS 执行环境,可以和主线程通过消息通信,也可以传递 ArrayBuffer 或使用 SharedArrayBuffer。
// main.js
import { Worker } from 'node:worker_threads';
const worker = new Worker(new URL('./worker.js', import.meta.url), {
workerData: { count: 50_000_000 }
});
worker.on('message', (result) => console.log(result));
worker.on('error', (err) => console.error(err));
// worker.js
import { parentPort, workerData } from 'node:worker_threads';
let sum = 0;
for (let i = 0; i < workerData.count; i++) sum += i;
parentPort.postMessage(sum);
worker 的优势是比进程更轻,适合频繁执行计算任务。代价是通信、序列化和线程管理也有成本。一个 5ms 的小任务丢给 worker,可能通信成本就抵消收益;一个 800ms 的计算任务才更值得隔离。
三、child_process 适合进程隔离和外部命令
child_process 会启动独立进程,常用 API 有 spawn、exec、execFile、fork。它适合调用系统命令、运行独立脚本、隔离容易崩溃的任务。
import { spawn } from 'node:child_process';
const ps = spawn('node', ['scripts/build-report.js'], {
stdio: ['ignore', 'pipe', 'pipe']
});
ps.stdout.on('data', (chunk) => console.log(chunk.toString()));
ps.on('exit', (code) => console.log('exit', code));
spawn 更适合大输出流式处理,exec 会把输出缓存到内存,输出很大时容易出问题。假设命令输出 200 MB,exec 可能占用大量内存;spawn 可以边读边处理。
进程隔离的好处是子进程崩溃不一定拖垮主进程,权限、环境变量、资源限制也更容易隔离。代价是启动更重、通信更慢、内存占用更高。
四、cluster 适合 Web 服务多进程扩容
cluster 的目标是让多个 Node 进程共享服务端口,提高多核利用率。一个 master 或 primary 进程管理多个 worker 进程,每个 worker 都有自己的事件循环。
primary
-> worker 1: event loop
-> worker 2: event loop
-> worker 3: event loop
-> worker 4: event loop
假设 4 核机器上单进程 Node 服务 QPS 上限约 1000,合理多进程后可能接近 3000 到 4000,具体取决于数据库、网络、业务计算和负载均衡开销。cluster 解决的是服务实例并行,不是单个 JS 对象在多个进程中共享。
现代生产也常用 PM2、容器副本、Kubernetes Deployment 或进程管理器实现类似多进程扩容。面试时可以说 cluster 是 Node 内建方案,但工程上也会结合容器和网关负载均衡。
五、通信和数据传递成本决定了边界
把任务移出去不等于免费。主线程和 worker、子进程之间要通信,数据要复制、转移或共享。大对象传来传去会产生明显成本。
| 方案 | 隔离级别 | 通信成本 | 适合任务 |
|---|---|---|---|
worker_threads | 线程隔离 | 中等,可转移内存 | CPU 密集 JS |
child_process | 进程隔离 | 较高,IPC/stdio | 外部命令、强隔离 |
cluster | 多进程服务 | 请求级分发 | Web 服务多核扩容 |
| 外部任务服务 | 服务隔离 | 网络成本 | 重任务、可异步化业务 |
例如生成一个 20 MB 的报表,如果主线程把完整大对象传给 worker,再把完整结果传回来,光序列化和内存复制就可能很重。更好的设计可能是把输入压缩成任务参数,让 worker 自己读取数据源并输出文件路径。
记忆钩子:并行不是免费午餐。计算省下来的时间,要和启动、通信、复制、监控、失败处理的成本一起算。
六、工程选择要看失败和治理
worker 线程抛错,需要主线程监听 error 并决定是否重建 worker。子进程退出,需要处理 exit code、stderr、超时和资源清理。cluster worker 崩溃,需要拉起新进程并做好连接排空。
提交任务 -> 设置超时 -> 捕获错误 -> 记录上下文
-> 释放资源 -> 重试或降级 -> 上报指标
不要只写“开 worker 就行”。真实服务还要限制并发任务数,否则 8 核机器上一次创建 1000 个 worker,会把 CPU 和内存打爆。通常会做 worker pool、任务队列和背压控制。
前端 Node BFF 中,如果只是请求聚合和轻量字段处理,不需要 worker;如果要做图片压缩、PDF 渲染、大文件解析、复杂规则计算,就要考虑隔离方案。
七、常见误区与追问
- 误区:cluster 可以让一个请求里的大计算自动变快。 cluster 增加的是进程数量,单个请求分到某个 worker 后仍可能阻塞该进程。
- 误区:worker_threads 比 child_process 永远更好。 worker 更轻但隔离弱,执行不可信命令或需要强隔离时子进程更合适。
- 误区:任务丢出去就没有性能成本。 通信、序列化、内存复制、启动和调度都要成本,小任务可能不划算。
- 追问:CPU 密集任务为什么不用 libuv 线程池自动处理? 普通 JS 大循环不会自动进入 libuv 线程池,需要 worker 或进程隔离。
- 追问:spawn 和 exec 怎么选? 大输出、长运行任务用
spawn流式处理;小命令且需要完整输出时可用exec,但要注意缓冲区。 - 追问:生产中 cluster 之外还有什么方案? PM2、容器多副本、Kubernetes、进程管理器和网关负载均衡都可实现多实例扩容。
八、加强记忆
选择题可以记成“三把刀”:worker_threads 切 CPU 计算,child_process 切进程隔离和外部命令,cluster 切 Web 服务多核扩容。再补一句成本意识:任务越重越值得隔离,数据越大越要减少传输,崩溃越危险越要强隔离。