Node.js 跑 JS 是单线程的,这意味着默认情况下,无论你的服务器是 2 核还是 32 核,一个 Node 进程只能用满一个核。剩下的核都在闲着 —— 这显然是浪费。
这篇讲清 Node 利用多核的三种武器:cluster、child_process、worker_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 上限,输出太大会报错) - 执行固定可执行文件、不走 shell →
execFile(更安全,避免命令注入) - 跑另一个 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、内存监控的手工活,但进程隔离铁律不变
把这几个工具的边界划清楚,你就能根据"是扩吞吐、调命令、还是啃计算"对症下药,真正让多核硬件物尽其用。