多进程与多线程
Node.js 的核心特性是单线程和异步非阻塞 I/O。这使得它在处理 I/O 密集型任务(如高并发的 HTTP 请求、数据库读写)时表现极其出色。
然而,单线程也带来了两个致命缺陷:
- 无法利用多核 CPU:一台 8 核服务器,Node.js 默认只能使用 1 个核,造成极大资源浪费。
- 不擅长 CPU 密集型任务:如果执行一段非常耗时的同步计算(如巨量数组排序、加密解密、复杂的正则匹配),整个事件循环(Event Loop)会被阻塞,导致所有其他用户的请求都无法被响应。
为了解决这两个问题,Node.js 提供了完善的多进程(child_process, cluster)和多线程(worker_threads)方案。
多进程 (Multi-process)
多进程架构主要用来解决利用多核 CPU和提升服务稳定性的问题。
child_process 模块
Node 提供了四个核心 API 用于创建子进程:
exec:衍生一个 shell 来运行命令,将输出缓存起来后回调返回。适合执行简单的系统命令(输出量小)。execFile:直接执行可执行文件,不衍生 shell,性能比exec高,更安全。spawn:以流(Stream)的方式实时返回数据。适合处理海量数据或长时间运行的任务。fork(最高频):专门用来衍生新的 Node.js 进程。它不仅衍生进程,还会在父子进程之间自动建立一条 IPC(进程间通信)通道,可以直接使用process.send()和process.on('message')进行通信。
Cluster 模块与端口共享机制
在生产环境中部署 Node.js 服务时,通常会使用 Cluster 模块(或者基于它的 PM2)来启动多个进程。
🔥 面试题:Node.js 是如何实现多个进程监听同一个端口的?为什么不会报“端口被占用(EADDRINUSE)”?
这是一个区分度极高的底层面试题。
- 常规认知:操作系统的网络模型中,一个端口在同一时刻只能被一个进程绑定监听。如果子进程直接
listen(3000),必定报错。 - Node.js 底层原理:
- 在 Cluster 模式下,真正监听底层端口的只有主进程(Master),它会在内部创建一个 TCP 服务器。
- 子进程(Worker)里面调用的
server.listen(3000)其实是被 Node.js 拦截和 hack 过的,它并不会真正在底层去调用操作系统的bind/listen。 - 主进程接收到外部的网络请求连接后,会根据指定的负载均衡算法(通常是 Round-Robin 轮询),通过 IPC 通道将这个 socket 句柄(Handle)发送给某一个空闲的子进程去处理。
- 这种底层机制叫做句柄传递(依赖操作系统的
sendmsg系统调用)。因此,外部看来是多个进程在监听同一个端口,实际上只有 Master 占用了端口,Worker 只是拿到了主进程丢过来的句柄去处理请求。
什么是「句柄(Handle)」?
句柄 = 内核分配给资源的整数编号(Linux 下就是 fd 文件描述符)。在 Linux「一切皆文件」的设计下,socket、管道、文件、信号量都被抽象成 fd(一个非负整数)。
- 用户态程序只能拿到这个整数,不能直接操作底层资源
- 句柄 vs 指针:指针指向用户态内存(JS 对象),句柄指向内核态资源
- 跨进程时指针失效(地址空间隔离),但 fd 在
fork()时自动继承给子进程
// 进程级 fd 编号示例(lsof -p <pid> 可见)
0; // stdin
1; // stdout
2; // stderr
3; // 第一个被打开的 socket
Node.js 句柄传递走的是 OS 的
sendmsg(2)+SCM_RIGHTS通道,真正把内核 socket 对象传给子进程,子进程 fd 表新增条目指向同一对象——这就是"多进程监听同端口"的本质。
IPC 通信原理
面试题:Node.js 父子进程是怎么通信的?
- Node.js 的 IPC(Inter-Process Communication)底层是通过 Libuv 实现的。
- 在 Windows 下基于 命名管道 (Named Pipe) 实现;在 *nix (Linux/macOS) 下基于 Unix Domain Socket 实现。
- 只有
fork()会自动建立 IPC 通道,spawn/exec默认不建(需要显式传stdio: 'ipc')。 - 序列化机制:默认走 V8 结构化克隆(Structured Clone),而不是
JSON.stringify——所以能传Date/Buffer/TypedArray,但传不了函数或Symbol。
进阶考点:除了数据,IPC 还能传句柄(
worker.send(msg, serverHandle)),这是 Cluster 端口共享、文件 fd 传递的核心机制。
多线程 (Multi-thread)
多进程虽然能利用多核,但进程非常重(每个进程都有自己独立的 V8 实例、事件循环和内存空间,起一个进程至少消耗 30MB 内存)。如果只是想算个复杂的数学题就起一个进程,代价太大了。
为了专门解决 CPU 密集型任务,Node.js 在 v10 之后引入了真正的多线程:worker_threads 模块。
Worker Threads 的特点
- 共享内存:这是线程相比进程最大的优势!Worker Threads 可以通过
SharedArrayBuffer实现多线程之间真正的内存共享,极大地避免了进程间通信序列化/反序列化的性能开销。 - 独立的执行环境:每个 Worker 线程有自己的 V8 引擎实例、Event Loop 事件循环。但它们同属于一个宿主进程,内存消耗远小于子进程。
- 通信机制:线程之间通过
MessageChannel(类似于前端 Web Worker 的postMessage) 进行通信。
适用场景
- 图像/视频处理
- 大规模数据加解密
- 极其复杂的 JSON 解析或数据过滤
// 多线程基础示例
const { Worker, isMainThread, parentPort } = require("worker_threads");
if (isMainThread) {
// 主线程
const worker = new Worker(__filename); // 将当前文件作为 Worker 运行
worker.on("message", (msg) => console.log("收到线程结果:", msg));
worker.postMessage("开始计算吧");
} else {
// Worker 线程
parentPort.on("message", (msg) => {
// 模拟一段极度耗时的 CPU 密集型计算
let count = 0;
for (let i = 0; i < 1000000000; i++) count++;
parentPort.postMessage(count);
});
}
共享内存原理:SharedArrayBuffer + Atomics
线程间共享内存不是自动同步的——必须配合 Atomics API 才安全。
核心机制:OS 级共享内存
SharedArrayBuffer 底层是同一块物理内存被映射到多个 V8 实例的地址空间,postMessage 传引用时零拷贝:
// 主线程
const shared = new SharedArrayBuffer(4);
const view = new Int32Array(shared);
new Worker("./sum.js", { workerData: shared });
// sum.js —— Worker 看到的是同一块物理内存
const { workerData } = require("worker_threads");
const view = new Int32Array(workerData);
为什么必须用 Atomics?
view[0] = view[0] + 1 实际是 3 条机器指令(LOAD → ADD → STORE),多线程并发会丢更新。Atomics.add 底层走 CPU 硬件原子指令(x86 LOCK XADD),整个「读 + 改 + 写」不可中断:
// ❌ 会丢数据
view[0] = view[0] + 1;
// ✅ 原子操作,零丢失
Atomics.add(view, 0, 1);
面试话术:
Atomics是 JS 对 CPU 原子指令的封装,配套Atomics.wait/notify(基于futex)做线程间阻塞唤醒。
浏览器端的「跨源隔离」要求
面试常考点:为什么
SharedArrayBuffer一度消失又回来?
- 2018 年:Spectre 漏洞披露,SAB 配合高精度计时器可侧信道攻击读取其他进程内存 → 浏览器默认禁用 SAB。
- 2021 年起:W3C 引入跨源隔离策略,页面必须同时设置两个响应头才能用:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
if (crossOriginIsolated) {
new SharedArrayBuffer(1024); // 满足跨源隔离才可用
}
面试总结:多进程 vs 多线程,怎么选?
🔥 面试题:在 Node.js 中,多进程和多线程有什么区别?在什么场景下分别使用?
| 对比维度 | 多进程 (cluster / child_process) | 多线程 (worker_threads) |
|---|---|---|
| 资源开销 | 极大。每个进程是独立的 Node 实例,独占内存和 V8。起成百上千个会瞬间 OOM。 | 较小。同一个进程下分配的线程,启动快,内存占用小。 |
| 数据共享 | 默认内存隔离,无法共享。必须通过 IPC 传递,有序列化开销。 | 可通过 SharedArrayBuffer 直接共享内存,通信效率极高。 |
| 稳定性 | 极高。一个子进程崩溃(如 OOM),完全不影响其他进程和主进程。 | 较弱。如果一个线程因为 C++ 层面的底层错误引发了 segment fault,可能会导致整个宿主进程崩溃。 |
| 核心适用场景 | Web 服务的负载均衡。用于开启多实例监听 HTTP 请求(如 PM2)。 | 处理局部的 CPU 密集型任务。如加密计算、视频处理,算完就把结果返回给主线程。 |
一句话总结你的回答:
“在 Node.js 的架构设计中,如果要提升 Web 服务的整体并发吞吐量,利用多核 CPU,我们应当使用 Cluster 模块(多进程);如果仅仅是为了防止某段复杂的 CPU 运算阻塞主事件循环,我们应当使用 worker_threads(多线程)将任务扔到后台去算。”