进程与线程深度解析
Node 单线程不等于只能用一个核:child_process 跑子进程、cluster 多实例吃满多核、worker_threads 做真多线程,选型与坑位一图说清。
一句话概括
「Node 是单线程」指的是 JS 代码在主线程上执行,但 Node 绝不只能用一个 CPU 核。它提供了三套并行武器:child_process 跑独立子进程、cluster 起多个 worker 吃满多核 HTTP 服务、worker_threads 做真正的线程级并行。
面试常把它当分水岭题:能说清「这三者分别解决什么问题、通信成本差多少、什么时候该用哪个」,就说明你不只是会用 Node,而是懂它的并发模型。当前 LTS(Node 22 / 24)中三者 API 稳定可用。
核心知识点
1. child_process:四种姿势跑外部程序
| API | 是否走 shell | 输出方式 | 典型用途 |
|---|---|---|---|
spawn | 否 | 流式(stdout/stderr) | 大输出、长时间命令 |
exec | 是 | 缓冲后回调一次性返回 | 简单命令、小输出 |
execFile | 否 | 缓冲后回调 | 直接执行文件,避免注入 |
fork | 否(专跑 Node) | IPC 消息 | 跑 Node 脚本并通信 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const { spawn, exec, execFile, fork } = require('child_process');
// ✅ 大量输出、流式处理:用 spawn(默认不进 shell)
const ls = spawn('ls', ['-la']);
ls.stdout.on('data', d => console.log(d.toString()));
// ✅ 简单命令、小输出:用 exec(走 shell,注意注入风险)
exec('ls -la', (err, stdout) => console.log(stdout));
// ✅ 跑别的 Node 脚本且要通信:用 fork(自带 IPC,不进 shell)
const child = fork('./worker.js');
child.send({ task: 'calc' });
child.on('message', msg => console.log(msg));
// ❌ 把用户输入拼进 exec:命令注入!
exec(`ls ${userInput}`); // 用户输入 "; rm -rf /" 就完了
// ✅ 用 execFile / spawn 传参数数组,绕开 shell
execFile('ls', [userInput]);
2. cluster:一个端口,多个 worker 吃满多核
cluster 基于 child_process.fork,但封装了端口共享 + 负载均衡——多个 worker 同时 listen 同一个端口不冲突,因为主进程把 socket 的文件描述符发给各 worker:
1
2
3
4
5
6
7
8
9
10
11
const cluster = require('cluster');
const http = require('http');
const os = require('os');
if (cluster.isPrimary) { // v16+ 推荐 isPrimary(旧版 isMaster)
os.cpus().forEach(() => cluster.fork());
cluster.on('exit', w => cluster.fork()); // 挂了自动拉起
} else {
http.createServer((req, res) => res.end(`worker ${process.pid}`)).listen(3000);
}
// 多个 worker 同时 listen(3000) 不冲突:主进程向各 worker 传递 socket 文件描述符
注意:旧 API
cluster.isMaster已更名为isPrimary(v16 起),但isMaster仍保留兼容。面试提一句显得跟版本。
需要「同一用户落到同一 worker」(粘性会话)时,要么前面加 nginx 的 ip_hash,要么自己按客户端 IP 哈希分发连接。
3. worker_threads:真正的线程级并行
worker_threads 是同一进程内的多线程,共享进程内存、通信成本低,专为 CPU 密集型 任务设计:
1
2
3
4
5
6
7
8
9
10
11
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
if (isMainThread) {
const w = new Worker(__filename, { workerData: { n: 1e8 } });
w.on('message', r => console.log('结果', r));
} else {
const { n } = workerData;
let sum = 0;
for (let i = 0; i < n; i++) sum += Math.sqrt(i);
parentPort.postMessage(sum); // 把结果发回主线程
}
线程间要共享状态,用 SharedArrayBuffer + Atomics 做线程安全读写:
1
2
3
4
5
6
7
8
9
10
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
if (isMainThread) {
const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab);
new Worker(__filename, { workerData: sab }).on('exit', () =>
console.log('共享计数 =', view[0])); // 期望 1_000_000
} else {
const view = new Int32Array(workerData);
for (let i = 0; i < 1e6; i++) Atomics.add(view, 0, 1); // 线程安全自增
}
4. 三者怎么选
| 维度 | child_process | cluster | worker_threads |
|---|---|---|---|
| 本质 | 新进程 | 同进程多实例 | 同进程内线程 |
| 通信 | IPC(序列化) | IPC + socket 共享 | 消息 + 共享内存 |
| 内存开销 | 高(独立空间) | 较高(每 worker 一份) | 低(共享进程) |
| 隔离性 | 完全隔离 | 进程级隔离 | 不隔离(一崩全崩) |
| 适用 | 系统命令、隔离任务 | 多核 HTTP 服务 | CPU 密集型计算 |
其实你每天都在用
npm run build里调 tsc / esbuild:child_process起子进程跑构建- PM2 /
node -icluster 模式:多核部署,自动拉起挂掉的 worker sharp压缩图片:底层常驻worker_threads做 CPU 重活- 生产起多个实例:
cluster把 32 核服务器吃满 - CI 里并行跑测试:多进程分片,缩短流水时间
- 服务端加密签名 / 复杂计算:
worker_threads卸载主线程,不阻塞请求
常见误解(FAQ)
❌ 误区一:「Node 是单线程,所以只能用一个 CPU 核」
单线程指 JS 执行在主线程,但 cluster 能 fork 出多个 worker 进程、每个进程一个事件循环,从而吃满多核;CPU 重活还能丢给 worker_threads。「单线程」≠「单核」。
❌ 误区二:「worker_threads 能提速所有场景」
不能。worker_threads 的设计目标是 CPU 密集型 任务。I/O 密集型本就是事件循环的强项,再加线程只会增加「线程创建 + 消息传递」的开销,反而更慢。
❌ 误区三:「cluster 里每个 worker 各自 listen 不同端口」
不需要。cluster 的关键就是多个 worker 共享同一个端口——主进程 accept 连接后通过文件描述符传递给 worker,所以你只写一份 listen(3000),所有 worker 都在 3000 上接客。
❌ 误区四:「fork 是 exec 的语法糖」
不是。fork 专用于启动另一个 Node.js 脚本,且自动建立 IPC 通道(child.send / process.on('message')),不走 shell;exec 是「执行任意命令字符串、走 shell、缓冲输出」。两者定位完全不同。
一句话总结
Node 的并发不是「一个线程扛天下」,而是「子进程干脏活、cluster 吃满多核、worker_threads 卸载 CPU 重活」——选对武器,单线程的 Node 也能把 32 核服务器榨干,又不阻塞一个请求。