返回首页

Node.js 多进程:cluster、child_process 与 worker_threads 怎么选

单线程的 Node 如何吃满多核?三种多进程/多线程方案的定位与取舍,以及生产里用 PM2 的原因。

14 约 5 分钟 · 4454 字 后端

Node.js 跑 JS 是单线程的,这意味着默认情况下,无论你的服务器是 2 核还是 32 核,一个 Node 进程只能用满一个核。剩下的核都在闲着 —— 这显然是浪费。

这篇讲清 Node 利用多核的三种武器:clusterchild_processworker_threads,以及它们各自的适用场景。

为什么需要多进程

设想一台 4 核服务器跑一个 Node Web 服务。单进程模式下,即使 CPU 还有 75% 空闲,你的吞吐也上不去,因为所有请求都挤在一个核上排队。

解决思路很直接:起多个进程,每个进程占一个核,让操作系统把请求分摊给它们。这就是 cluster 模块要做的事。

cluster:多个进程共享同一个端口

cluster 最神奇的地方是:多个 worker 进程可以监听同一个端口而不冲突。秘密在于,真正绑定端口的是 master 进程,它把进来的连接分发给各个 worker。

import cluster from 'node:cluster'
import http from 'node:http'
import { availableParallelism } from 'node:os'

if (cluster.isPrimary) {
  // 主进程:按 CPU 核数 fork 出 worker
  const cpuCount = availableParallelism()
  console.log(`主进程启动,fork ${cpuCount} 个 worker`)

  for (let i = 0; i < cpuCount; i++) {
    cluster.fork()
  }

  // worker 意外退出就重启,保证服务不中断
  cluster.on('exit', (worker, code) => {
    console.log(`worker ${worker.process.pid} 退出(${code}),重启中`)
    cluster.fork()
  })
} else {
  // worker 进程:正常起 HTTP 服务,多个 worker 共享 3000 端口
  http
    .createServer((req, res) => {
      res.end(`处理者 PID: ${process.pid}`)
    })
    .listen(3000)
}

刷新几次页面,你会看到响应里的 PID 在几个值之间轮换 —— 请求被分发给了不同的 worker。

关于负载均衡:Node 的 cluster 在不同平台策略不同。Linux 上默认用**轮询(round-robin)**由主进程分发,相对均匀;Windows 等平台是让 worker 直接抢连接,可能不太均匀。

进程隔离的代价:不能共享内存

多进程模型有一条铁律必须刻进脑子:进程之间内存完全隔离,不共享任何变量

这意味着下面这种写法在 cluster 下是错的:

// ❌ 每个 worker 各有一份 loginAttempts,互相看不见
const loginAttempts = new Map()

app.post('/login', (req, res) => {
  // worker A 记的次数,worker B 根本不知道
  // 限流、计数器、session 全部失效
})

任何需要"全局共享"的状态 —— 限流计数、登录失败次数、缓存、session —— 都不能放进程内存,得放到进程外的共享存储里:Redis、数据库,或专门的服务。这是从单进程迁移到多进程时最容易翻车的地方。

child_process:执行外部命令或脚本

cluster 是为"复制 Web 服务"设计的。如果你要做的是"调用一个系统命令""跑一个独立脚本",那是 child_process 的活。它有几个方法,别用混了:

import { spawn, exec, execFile, fork } from 'node:child_process'

// spawn:流式输出,适合大量/持续的输出(如 ffmpeg、长任务)
const ls = spawn('ls', ['-lh', '/usr'])
ls.stdout.on('data', (data) => console.log(`输出: ${data}`))

// exec:把输出一次性 buffer 起来给你,简单但有大小上限(maxBuffer)
exec('cat *.log | wc -l', (err, stdout) => {
  console.log(`行数: ${stdout}`)
})

// fork:专门用来跑另一个 Node 脚本,自带 IPC 通道,父子能发消息
const child = fork('./worker-task.js')
child.send({ task: 'compute', payload: [1, 2, 3] })
child.on('message', (result) => console.log('子进程算完了:', result))

选择口诀:

  • 输出大 / 持续spawn(流式,不怕撑爆内存)
  • 输出小 / 图省事exec(注意默认 buffer 上限,输出太大会报错)
  • 执行固定可执行文件、不走 shellexecFile(更安全,避免命令注入)
  • 跑另一个 Node 脚本还要通信fork(自带 IPC)

worker_threads:CPU 密集任务的正解

前面说过,事件循环最怕 CPU 密集的同步计算会把主线程卡死。cluster 能缓解(多个进程分担),但进程开销大、还不能共享内存。

Node 10+ 提供的 worker_threads 更适合这种场景 —— 它是线程而非进程,更轻量,而且能通过 SharedArrayBuffer 共享内存:

import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'

if (isMainThread) {
  // 主线程:把繁重计算丢给工作线程,自己继续响应请求
  const worker = new Worker(new URL(import.meta.url), {
    workerData: { n: 42 },
  })
  worker.on('message', (result) => console.log('计算结果:', result))
} else {
  // 工作线程:闷头算,不阻塞主线程的事件循环
  const result = heavyFib(workerData.n)
  parentPort.postMessage(result)
}

用工作线程做大计算,主线程的事件循环始终通畅,Web 请求该响应响应。

三者怎么选

一张表理清定位:

方案 本质 共享内存 最适合
cluster 多进程,复制 Web 服务 I/O 密集的 Web 服务,横向吃满多核
child_process 子进程,执行命令/脚本 调系统命令、跑独立任务
worker_threads 多线程,同进程内 ✅(SharedArrayBuffer) CPU 密集计算(图像处理、加密、大量运算)

一句话总结:扩 Web 吞吐用 cluster,跑外部程序用 child_process,啃 CPU 大活用 worker_threads

生产里:为什么用 PM2 而不是裸 cluster

手写 cluster 能跑,但生产环境你会发现还要自己处理:进程崩溃重启、平滑重载(reload 不断服)、日志收集、内存超限自动重启、开机自启…… 这些 PM2 都帮你封装好了:

# cluster 模式,按 CPU 核数起进程
pm2 start app.js -i max --name my-app

# 平滑重载,不中断在途请求
pm2 reload my-app

# 内存超过阈值自动重启(防内存泄漏拖垮机器)
pm2 start app.js --max-memory-restart 500M

所以实际项目里,你很少手写 cluster,而是让 PM2 以 cluster 模式托管。但进程隔离那条铁律不变 —— 上了 PM2 cluster,共享状态照样得搬到 Redis / 数据库,别留在进程内存里。

小结

  • Node 单进程只能用一个核,多核机器需要多进程/多线程才能吃满
  • cluster:多 worker 共享端口,master 分发连接,适合 I/O 密集 Web 服务横向扩展
  • 多进程内存完全隔离:限流、计数、session、缓存等共享状态必须放 Redis/数据库,这是最大的坑
  • child_process:spawn(流式大输出)/ exec(小输出图省事)/ execFile(安全)/ fork(跑 Node 脚本带 IPC)
  • worker_threads:轻量线程、可共享内存,CPU 密集计算的正解
  • 生产用 PM2 托管 cluster,省去重启、reload、内存监控的手工活,但进程隔离铁律不变

把这几个工具的边界划清楚,你就能根据"是扩吞吐、调命令、还是啃计算"对症下药,真正让多核硬件物尽其用。