🌐 Comparing Node.js concurrency models
Node.js 在单线程上运行你的 JavaScript,由 事件循环 驱动。这种模型非常适合 I/O 密集型工作——读取文件、查询数据库、处理 HTTP 请求——因为 Node 会将等待交给操作系统处理,同时保持线程空闲。
🌐 Node.js runs your JavaScript on a single thread, driven by the event loop. That model is great for I/O-bound work — reading files, querying a database, handling HTTP requests — because Node hands off the waiting to the operating system and keeps the thread free.
不过,它不适合另外两种情况:
🌐 It's a poor fit for two other situations, though:
- CPU 密集型工作(图片调整大小、哈希计算、解析庞大负载)会占用单个线程,并在运行时使整个应用无响应。
- 跨 CPU 核心扩展 — 单个 Node.js 进程的 JavaScript 仅使用一个核心,无论机器有多少核心。
Node.js 提供了三个内置模块来解决这个问题:node:child_process、node:worker_threads 和 node:cluster。它们以不同的方式解决重叠的问题,这就是为什么很容易选择错误的模块。本文对它们进行了并排比较,并讲解了何时使用每一个。
🌐 Node.js ships three built-in modules to address this: node:child_process, node:worker_threads, and node:cluster. They solve overlapping problems in different ways, which is why it's easy to reach for the wrong one. This guide compares them side by side and walks through when to use each.
🌐 The three options at a glance
| 运行方式 | 内存 | 通信方式 | 最适合 | |
|---|---|---|---|---|
child_process | 独立进程 | 隔离 | stdio,或 IPC (fork()) | 运行外部程序 |
worker_threads | 独立线程 | 可共享 | postMessage() | CPU 密集型 JS 工作 |
cluster | 多进程 | 隔离 | IPC | 跨核心扩展服务器 |
区分它们的一个有用方法:cluster 是建立在 child_process 之上,专门用来解决“跨核心扩展服务器”问题,而 worker_threads 的存在是为了解决“在不阻塞的情况下运行 CPU 密集型代码”的问题,而无需付出创建整个新进程的代价。
🌐 A useful way to tell them apart: cluster is built on top of child_process specifically to solve the "scale a server across cores" problem, while worker_threads exists to solve the "run CPU-heavy code without blocking" problem without paying the cost of a whole new process.
🌐 Child Process
当你需要从 Node.js 运行另一个程序——一个 shell 命令、一个二进制文件、另一种语言的脚本——或者当你想要一个通过 stdio/IPC 驱动的完全独立的 Node.js 进程时,使用 child_process。
🌐 Use child_process when you need to run another program — a shell command, a binary, a script in another language — from Node.js, or when you want a fully isolated Node.js process that you drive over stdio/IPC.
🌐 spawn, exec, execFile, and fork
| 方法 | 描述 | 使用场景 |
|---|---|---|
spawn() | 流式处理 stdout/stderr | 长时间运行、大量输出 |
exec() | 通过 shell 缓存完整输出 | 短命令、小量输出 |
execFile() | 类似 exec(),无 shell | 安全运行已知二进制文件 |
fork() | spawn() + Node.js 模块内置 IPC | 父子 Node.js 消息传递 |
exec() 会将子进程的整个 stdout/stderr 缓存到内存中,并且只在进程退出时回调,默认情况下结果被限制为 1 MiB (maxBuffer) —— 以这种方式尝试捕获大量或无限制的输出会导致截断并终止子进程。execFile() 完全避免启动 shell,这也规避了当命令部分来自用户输入时的 shell 注入风险。
🌐 Example: streaming a long-running command with spawn()
const { spawn } = require('node:child_process');
const child = spawn('ffmpeg', ['-i', 'input.mp4', 'output.mp3']);
child.stdout.on('data', data => console.log(`stdout: ${data}`));
child.stderr.on('data', data => console.error(`stderr: ${data}`));
child.on('close', code => console.log(`Process exited with code ${code}`));spawn() 会在输出生成时立即流式传输,而不是缓冲,所以它是处理任何长时间运行或输出大小不可预知的任务时的正确选择。
🌐 Example: talking to another Node.js process with fork()
const { fork } = require('node:child_process');
const child = fork('./child.js');
child.send({ task: 'start', data: 42 });
child.on('message', result => console.log('Result from child:', result));fork() 为你提供了一个现成的、基于结构克隆的 IPC 通道 (.send() / 'message'),因此你无需像使用 spawn() 那样自己解析 stdout。
🌐 Things to watch for
- 避免在可能产生大量输出的任何情况使用
exec()— 应使用spawn()并改为流式处理。 - 始终处理
'exit'和'close'事件,以便你能注意到失败并清理资源(打开的文件描述符、临时文件),而不是泄漏它们。 - 如果命令的任何部分来自用户输入,优先使用带有参数数组的
execFile()/spawn(),而不是exec(),因为后者通过 shell 执行,如果参数未经过清理,会存在 shell 注入风险。
🌐 Worker Threads
worker_threads 被添加进来是为了让你在同一进程内的线程中并行运行 JavaScript,具体目的是将 CPU 密集型工作从主线程移开而不阻塞它。与 child_process 不同,workers 共享同一进程——无需启动操作系统级的进程,也没有完全独立的内存空间。
| 工作线程 | 子进程 | |
|---|---|---|
| 上下文 | 同一进程 | 独立进程 |
| 内存 | 可通过 SharedArrayBuffer 共享 | 隔离 |
| 启动开销 | 低 | 较高 |
| 通信 | postMessage()(快速) | stdio 或 IPC(较慢) |
🌐 Example: offloading CPU-bound work
const { Worker } = require('node:worker_threads');
const worker = new Worker('./hash-worker.js', {
workerData: 'user-password',
});
worker.on('message', hash => console.log('Computed hash:', hash));
worker.on('error', err => console.error('Worker failed:', err));密码哈希、图片/视频转换、加密以及解析非常大的内存负载是典型的候选任务:它们完全是 CPU 工作,如果在主线程上运行它们,会阻塞 Node.js 同时处理的其他请求。
🌐 Password hashing, image/video transforms, encryption, and parsing very large in-memory payloads are classic candidates: they're pure CPU work, and running them on the main thread would stall every other request Node.js is handling in the meantime.
🌐 Sharing memory
工人可以使用 SharedArrayBuffer 结合 Atomics API 直接共享内存,以实现安全的并发访问,而不是通过 postMessage() 来反复复制数据。这可以避免大缓冲区的序列化开销,但现在你需要自己负责协调访问——这可能引入与传统多线程编程中相同类型的竞争条件错误。
🌐 Workers can share memory directly using SharedArrayBuffer together with the Atomics API for safe concurrent access, instead of copying data back and forth with postMessage(). This avoids serialization overhead for large buffers, but you're now responsible for coordinating access yourself — it opens the door to the same race-condition class of bugs found in traditional multi-threaded programming.
🌐 Things to watch for
- 工作线程用于 CPU 密集型工作,而不是 I/O——工作线程仍然必须像主线程一样等待 I/O,因此为了数据库调用或网络请求启动一个工作线程只会增加开销而没有任何好处。
- 启动一个工作进程是有实际成本的。对于小且频繁的任务,这种设置开销可能会超过其带来的好处——可以考虑使用一个长期存在的工作进程池(参见
workerpool包),而不是为每个任务创建一个新的工作进程。
🌐 Cluster
cluster 解决了一个不同的问题:单个 Node.js 进程只能使用一个 CPU 核心。为了在多核机器上为网络服务器提供性能,cluster 会派生出多个完整的进程副本(每个在底层都是一个 child_process),它们都监听同一个端口。
🌐 Example: scaling an HTTP server across cores
const cluster = require('node:cluster');
const http = require('node:http');
const { availableParallelism } = require('node:os');
if (cluster.isPrimary) {
const numCPUs = availableParallelism();
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
if (worker.exitedAfterDisconnect) {
// A voluntary exit (e.g. worker.disconnect() or cluster.disconnect())
// — don't respawn.
return;
}
console.log(`Worker ${worker.process.pid} died, restarting`);
cluster.fork();
});
} else {
http
.createServer((req, res) => {
res.writeHead(200);
res.end('handled by worker ' + process.pid);
})
.listen(3000);
}主进程(cluster.isPrimary)为每个核心创建一个工作进程,并重新启动任何意外退出的工作进程。每个工作进程(cluster.isWorker)运行你的常规服务器代码,而不会意识到自己是多个进程中的一个。worker.exitedAfterDisconnect 标志区分自愿退出(例如通过 worker.disconnect() 或 cluster.disconnect() 进行的优雅关闭)与崩溃——如果不检查它,有意关闭将会不断生成新的工作进程,而不是正常关闭。
🌐 The primary process (cluster.isPrimary) forks one worker per core and restarts any worker that exits unexpectedly. Each worker (cluster.isWorker) runs your normal server code, unaware that it's one of several. The worker.exitedAfterDisconnect flag distinguishes a voluntary exit (e.g. during a graceful shutdown via worker.disconnect() or cluster.disconnect()) from a crash — without checking it, a deliberate shutdown would keep spawning new workers instead of winding down.
🌐 How the workers share one port
与依赖操作系统级别的机制不同,主进程本身将传入的连接分配给其工作进程——默认情况下采用轮询方式(cluster.SCHED_RR),这是除 Windows 外每个平台的默认设置。这个可以通过 cluster.schedulingPolicy 配置。(你可能会看到旧资料声称这是通过 SO_REUSEPORT 完成的;但这并不是 Node 的 cluster 模块的默认做法。)
🌐 Rather than relying on an OS-level mechanism, the primary process itself distributes incoming connections to its workers — by default in a round-robin fashion (cluster.SCHED_RR), which is the default on every platform except Windows. This is configurable via cluster.schedulingPolicy. (You may see older material claim this is done via SO_REUSEPORT; that's not how Node's cluster module does it by default.)
cluster.isPrimary 是在分叉工作进程的过程中 true;cluster.isWorker 是在被分叉的工作进程自身中的 true。(你可能在较老的代码中也会看到 cluster.isMaster —— 它仍然可用,但已经被弃用,它是 isPrimary 的别名。)
🌐 Things to watch for
- 工作进程是独立的进程,因此它们不共享内存。内存中的会话、缓存或速率限制器在不同工作进程之间不可见——对于任何需要共享的内容,请使用像 Redis 这样的外部存储。
- 始终处理工作进程的
'exit'事件,并决定是否重启它;未处理的崩溃会悄无声息地降低服务器的容量。检查worker.exitedAfterDisconnect,以避免优雅关闭被当作崩溃处理,从而不断重启工作进程。 - 在生产环境中,大多数团队使用进程管理器(PM2、systemd 或容器编排器),而不是直接使用
cluster手动编写重启/重新加载逻辑。
🌐 Choosing between them
快速决策指南:
🌐 A quick decision guide:
- 运行外部程序,或用其他语言的脚本? →
child_process(spawn/execFile)。 - 以独立进程运行另一个 Node.js 脚本,并进行结构化消息传递? →
child_process.fork(). - CPU 密集型 JavaScript 阻塞你的事件循环(哈希计算、图片处理、大规模计算)? →
worker_threads。 - 想要使用所有 CPU 核心来处理更多的服务器流量吗? →
cluster(或者一个在基础设施层面执行相同操作的进程管理器/编排器)。
这些并不是互相排斥的。对于像图片上传服务这样的东西,一个常见的模式是将三者结合起来:使用 cluster 将传入请求分散到各个核心,然后在每个工作进程内部使用 worker_threads(或 child_process)进行实际的图片处理,而不会阻塞该工作进程的事件循环。
🌐 These aren't mutually exclusive. A common pattern for something like an image-upload service is to combine all three: cluster to spread incoming requests across cores, and worker_threads (or child_process) within each worker to do the actual image processing without blocking that worker's event loop.
🌐 Further reading