首页 / Node.js 教程 / 并发模型对比

Node.js 教程

并发模型对比

本教程共 76 篇 · 第 36 篇 · 更新于 2026-07-25 · 约 7 分钟阅读

Node.js并发clusterworker_threads选型

36. 并发模型对比

本节目标:cluster 与 worker_threads 的区别和选型建议。

前面三章我们分别讲了 child_processclusterworker_threads。它们都能让 Node.js “同时干多件事”,但底层机制和适用场景完全不同。选错了方案,轻则性能没提升,重则代码越写越复杂、bug 越修越多。

这一章我们把四个并发模型摊在桌面上,一条条对比,帮你建立选型的直觉。

四个选手

模型执行单元内存关系通信方式核心用途
单线程 + 事件循环一个线程统一内存无需通信I/O 密集型服务
child_process独立进程完全隔离stdio / IPC调用外部程序
worker_threads同进程多线程可共享postMessageCPU 密集型计算
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 最快(启动开销最小),clusterchild_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/catchworker.on('error')