并发模型对比
本教程共 76 篇 · 第 36 篇 · 更新于 2026-07-25 · 约 7 分钟阅读
36. 并发模型对比
本节目标:cluster 与 worker_threads 的区别和选型建议。
前面三章我们分别讲了 child_process、cluster 和 worker_threads。它们都能让 Node.js “同时干多件事”,但底层机制和适用场景完全不同。选错了方案,轻则性能没提升,重则代码越写越复杂、bug 越修越多。
这一章我们把四个并发模型摊在桌面上,一条条对比,帮你建立选型的直觉。
四个选手
| 模型 | 执行单元 | 内存关系 | 通信方式 | 核心用途 |
|---|---|---|---|---|
| 单线程 + 事件循环 | 一个线程 | 统一内存 | 无需通信 | I/O 密集型服务 |
child_process | 独立进程 | 完全隔离 | stdio / IPC | 调用外部程序 |
worker_threads | 同进程多线程 | 可共享 | postMessage | CPU 密集型计算 |
cluster | 多进程(同一份代码) | 完全隔离 | IPC | 多核扩展网络服务 |
单线程:别急着上并发
Node.js 默认的单线程异步模型,对绝大多数 Web 服务来说已经够用了。一个 HTTP 请求进来,读数据库、调接口、写文件,这些 I/O 操作都是异步的,不会堵住事件循环。一个进程能同时处理成千上万个并发连接,而且代码没有锁、没有竞态,心智负担极低。
很多人刚学 Node.js 就急着上 cluster 或者 worker,觉得 “多核不用就是浪费”。但其实很多业务的瓶颈根本不在 CPU,而在数据库慢查询、下游接口延迟、网络带宽。这时候加进程加线程,QPS 可能一点变化都没有。
Tip优化的第一条原则:先测量,再动手。用
perf_hooks(下一章讲)或者 APM 工具找到真正的瓶颈,别凭感觉猜。
child_process:调用外部程序
child_process 和其他三个不一样,它不是为了并发执行 JavaScript,而是为了启动别的程序。比如你需要:
- 调用 Python 脚本做机器学习推理
- 用
ffmpeg转码视频 - 执行
git命令获取仓库信息 - 起一个临时的 Docker 容器
import { spawn } from 'node:child_process';
const ffmpeg = spawn('ffmpeg', ['-i', 'input.mp4', 'output.mp3']);
ffmpeg.stdout.on('data', (data) => console.log(`stdout: ${data}`));
ffmpeg.stderr.on('data', (data) => console.error(`stderr: ${data}`));
ffmpeg.on('close', (code) => console.log(`子进程退出码: ${code}`));
spawn 适合长任务和大量输出,exec 适合短命令和少量输出(但有 1MB 缓冲限制),execFile 直接执行文件不经过 shell(更安全),fork 专用于启动 Node.js 子进程并建立 IPC 通道。
Warning
exec()会把整个 stdout/stderr 缓冲到内存里。如果子进程输出超过maxBuffer(默认 1MB),会直接报错并杀掉子进程。处理大输出时永远用spawn()配合流。
worker_threads:把 CPU 密集型任务 offload 出去
当你确定某个 JavaScript 函数是 CPU 密集型——比如密码哈希、图片处理、复杂数学计算、大数据解析——而且它会阻塞事件循环时,worker_threads 是首选。
它比其他方案轻量,因为多个线程共享同一个进程的 V8 实例,启动快、通信快。对于 “算完就返回结果” 的任务模型非常自然。
但它不适合这些场景:
- I/O 密集型:worker 里的 I/O 操作和主线程一样,还是要等,没有加速效果。
- 需要强隔离:线程崩溃可能导致整个进程挂掉,不像进程那样有边界。
- 大量可变共享状态:一旦用上
SharedArrayBuffer,你就得自己处理同步问题,复杂度直逼 C++ 多线程。
cluster:多核 HTTP 服务的标配
如果你的 Node.js 服务是 HTTP/TCP 服务器,而且 QPS 高到单个进程吃满了一个核,cluster 就是标准解法。它让每个核跑一个进程,共同监听一个端口,对外还是一个服务。
和 worker_threads 的本质区别在于:
cluster是为了扩展并发连接数,每个 worker 还是单线程事件循环。worker_threads是为了并行执行计算,把阻塞任务从事件循环上剥离。
两者甚至可以组合:用 cluster 把请求分派到多核,每个 worker 内部再用 worker_threads 处理图片转码之类的重计算。
选型决策树
面对一个具体需求,你可以按这个流程选:
需要执行外部程序(Python/shell/ffmpeg)?
└── 是 → child_process (spawn/exec)
└── 否 → 是 CPU 密集型 JS 计算?
└── 是 → 需要共享大量内存?
└── 是 → worker_threads + SharedArrayBuffer(谨慎)
└── 否 → worker_threads + postMessage
└── 否 → 是网络服务且单核吃满?
└── 是 → cluster(或 PM2)
└── 否 → 单线程异步足够
举几个具体例子:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| Express API 服务,QPS 过万 | cluster / PM2 | 多核扩展连接处理能力 |
| 用户上传图片后做压缩、加水印 | worker_threads | 图片处理是 CPU 密集型,不能阻塞事件循环 |
| 视频上传后调用 ffmpeg 转码 | child_process | 需要执行外部程序 |
| 普通 CRUD 后台,日活几千 | 单线程 | 瓶颈在数据库,加进程没用 |
| WebSocket 实时推送 | cluster | 但需要 sticky session 或 Redis 共享连接状态 |
| 大批量 CSV 数据解析和统计 | worker_threads | 纯 CPU 计算,可并行分片处理 |
组合使用的模式
真实项目里往往不是非此即彼。一个典型的图片处理服务可能是这样的架构:
┌─────────────────────────────────────────┐
│ 主进程 (Primary) │
│ 负责 fork 和管理 worker │
└─────────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│ Worker │ │ Worker │ │ Worker │ ← cluster 进程
│ :3000 │ │ :3000 │ │ :3000 │ 处理 HTTP 请求
└────────┘ └────────┘ └────────┘
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│ Thread │ │ Thread │ │ Thread │ ← worker_threads
│ 压缩图片 │ │ 压缩图片 │ │ 压缩图片 │ 处理 CPU 任务
└────────┘ └────────┘ └────────┘
这种三层结构(主进程 → cluster worker → worker thread)虽然看起来复杂,但每一层都解决一个特定问题,而且彼此职责清晰。
性能对比实测
我们用同一个斐波那契计算任务,对比四种执行方式的耗时。测试机器为 8 核 CPU,计算 fib(42) 4 次:
// 单线程顺序执行
console.time('单线程');
for (let i = 0; i < 4; i++) fib(42);
console.timeEnd('单线程'); // ~6s
// cluster 4 个进程,每个进程算一次(通过 HTTP 请求触发)
// 总耗时 ~1.8s(近似 4 核并行)
// worker_threads 4 个线程并行
console.time('worker_threads');
await Promise.all([...Array(4)].map(() => runWorker(42)));
console.timeEnd('worker_threads'); // ~1.6s
// child_process.fork 4 个子进程
console.time('child_process');
await Promise.all([...Array(4)].map(() => runFork(42)));
console.timeEnd('child_process'); // ~2.2s(进程启动开销更大)
对于纯 CPU 计算,worker_threads 最快(启动开销最小),cluster 和 child_process 差不多(都是多进程,有 V8 初始化成本),单线程最慢。但对于 HTTP 服务吞吐量的测试,cluster 完胜,因为 worker_threads 不帮你监听端口、不帮你做连接分发。
常见坑
坑 1:在 worker 里做 I/O 加速
有人以为 “worker 就是用来并发的”,把数据库查询也塞进去。实际上 worker 里的 fs.readFile 和主线程一样走 libuv 线程池,不会更快,反而多了线程通信开销。
坑 2:cluster + 内存 session = 用户掉登录
进程间内存隔离,req.session 存在 worker A 里,下次请求被分到 worker B,session 就不见了。要用 Redis 或 sticky session。
坑 3:worker_threads 里抛异常没捕获
worker 里未捕获的异常会让整个 Node.js 进程崩溃,而 cluster 里单个 worker 崩溃只会影响那一个进程。用 worker 时务必加 try/catch 和 worker.on('error')。