文章

进程与线程深度解析

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_processclusterworker_threads
本质新进程同进程多实例同进程内线程
通信IPC(序列化)IPC + socket 共享消息 + 共享内存
内存开销高(独立空间)较高(每 worker 一份)低(共享进程)
隔离性完全隔离进程级隔离不隔离(一崩全崩)
适用系统命令、隔离任务多核 HTTP 服务CPU 密集型计算

其实你每天都在用

  • npm run build 里调 tsc / esbuild:child_process 起子进程跑构建
  • PM2 / node -i cluster 模式:多核部署,自动拉起挂掉的 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 核服务器榨干,又不阻塞一个请求。

本文由作者按照 CC BY 4.0 进行授权