Node.js 的子进程、Worker 和集群应该怎样选择?
从 CPU 任务、外部命令和服务扩展三个场景区分 Worker Threads、child_process 与 cluster,说明通信、隔离、资源限制和故障恢复不能混为一谈。
简答
- Worker Threads 适合 CPU 密集的 JavaScript 计算,线程共享同一个进程。
- child_process 适合运行外部程序,或需要更强隔离的任务。
- cluster 让多个进程共享同一个端口,用来利用多核处理请求。
- 不同进程不共享内存中的状态,需要共享时使用外部存储或 IPC。
Node.js 的事件循环适合协调大量异步 I/O,但一段长时间的同步计算仍会阻塞当前进程。另一方面,调用 ffmpeg、git 或系统工具时,任务本来就属于外部程序;部署多份 HTTP 服务时,又需要把流量和故障分散到多个进程。
这三类问题分别对应不同工具:Worker Threads 在同一进程中运行 JavaScript 线程,child_process 启动独立操作系统进程,cluster 组织多个 Node 进程接收服务流量。它们不是“性能更强的三种同义 API”。
Worker Threads 解决线程级 CPU 隔离
Worker 适合可以拆成计算任务、又不需要独立操作系统权限的 JavaScript 工作。线程间传数据的三种成本见《Node.js Worker Threads 适合解决什么问题?》。
Worker 与主线程共享同一个进程的资源和故障边界。线程池设计见《Node.js 为什么要用 Worker Threads 任务池,而不是每次请求都创建 Worker?》。它也不是运行不可信代码的安全沙箱。
child_process 适合外部程序和更强进程边界
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const runFile = promisify(execFile);
export async function getImageMetadata(filePath) {
const { stdout } = await runFile('identify', ['-format', '%wx%h', filePath], {
shell: false,
timeout: 5_000,
maxBuffer: 64 * 1024,
});
return stdout.trim();
}
execFile 在不启用 shell 时可以避免一部分 shell 拼接风险,但参数仍要按工具语义校验。用户提供的路径不应直接成为任意可执行文件参数;应使用服务端维护的文件 ID 映射、限制目录和权限,并处理符号链接、超时、输出大小和退出码。
exec 会经过 shell,只有确实需要 shell 语法且参数已经严格固定时才考虑。spawn 适合持续读取 stdout/stderr 的长任务;fork 用于启动另一个 Node 模块并通过 IPC 通信。无论选择哪一个,子进程退出、被杀、输出爆满和父进程关闭都要有明确状态。
独立进程拥有自己的 V8 堆、事件循环和模块缓存。它可以限制或隔离一部分 CPU、内存和权限,但启动、序列化、IPC 和部署成本更高;进程边界也不是容器或操作系统安全策略的完整替代。
上面的 identify 是外部图像工具命令,不是 Node.js 内置 API;示例要求环境安装了对应可执行程序,ImageMagick 版本和系统发行方式可能采用不同入口。生产环境应固定可信可执行文件路径与版本,不依赖可被修改的 PATH 随机解析。即使 shell: false,用户参数以短横线开头、特殊格式语法或工具支持的协议形式出现时,仍可能被解释为选项或其他输入,必须按所调用工具的规则限制。
timeout 通常通过发送终止信号尝试结束子进程,不是整个进程树都已销毁的保证。子进程派生的后代、忽略信号的程序和平台差异需要额外处理;不能把 5000 毫秒理解为任务一定在该时刻彻底停止。maxBuffer 控制收集输出的大小,也不是子进程的内存配额。
cluster 主要解决多进程服务实例
HTTP 服务可以启动多个 Node 进程,让不同进程处理请求,从而利用多核并在一个进程异常时保留部分服务能力。cluster 提供进程间的组织和通信机制,但不替代反向代理、容器编排、健康检查和负载均衡。
每个进程拥有自己的内存、模块缓存和连接池。把会话、限流计数或缓存放在普通全局变量中,并不会在多个 worker 之间共享;需要共享时使用外部存储或明确的 IPC 协议。连接到本地文件的写入也要考虑多进程并发和锁。
集群中的一个进程崩溃后,其他进程仍可能持有旧配置或未完成请求。重启策略、优雅关闭、连接排空和幂等重试必须由应用和部署系统共同设计。把 cluster 当成“自动高可用”会掩盖状态丢失和单点依赖。
通信方式决定数据和错误语义
线程消息、子进程 IPC 和网络请求都可能复制数据、延迟交付、乱序结束或传递错误。不要因为发送函数返回就认为任务已经完成;需要业务确认时,应让执行端返回结构化结果、版本和错误分类。
大对象频繁跨边界传递可能比在主线程处理还慢。优先传递 ID、范围或可转移的缓冲区,并明确所有权;SharedArrayBuffer 可以减少线程间复制,但会带来数据竞争。浏览器的安全上下文与跨源隔离要求不能直接套到 Node.js Worker;普通子进程也不能像同进程 Worker 那样直接共享一个 JavaScript SharedArrayBuffer。
取消也要分别设计:worker.terminate() 或杀死子进程是强制停止,不保证 finally 执行,也不能撤销已经写文件、发请求或修改数据库的副作用。可恢复任务应采用分片、检查点、幂等 ID 和成功后提交。
先按问题选择,再看扩展方式
| 场景 | 优先考虑 | 主要代价 |
|---|---|---|
| 单个 CPU 密集 JavaScript 任务 | Worker Threads | 线程池、消息和内存管理 |
| 调用 ffmpeg、压缩工具或系统命令 | child_process | 参数安全、进程清理、输出和权限 |
| 多核运行 HTTP 服务 | 多进程部署或 cluster | 状态外置、健康检查、优雅发布 |
| 普通网络或文件异步 I/O | 原生异步 API | 不必额外创建线程/进程 |
如果一项任务需要强隔离、不信任第三方二进制或独立升级,通常应评估独立服务或容器,而不是把所有事情塞进一个 Node 进程。若只是偶发的小计算,优化算法或分片让出主线程可能比启动 Worker 更简单。
上线前要测量端到端时间、队列等待、CPU、堆内存、IPC 传输、进程重启和故障恢复。工具选择正确的证据不是“某个示例成功运行”,而是目标任务在真实负载下具备可解释的资源边界和恢复路径。