Skip to main content

多进程与多线程

Node.js 的核心特性是单线程异步非阻塞 I/O。这使得它在处理 I/O 密集型任务(如高并发的 HTTP 请求、数据库读写)时表现极其出色。

然而,单线程也带来了两个致命缺陷:

  1. 无法利用多核 CPU:一台 8 核服务器,Node.js 默认只能使用 1 个核,造成极大资源浪费。
  2. 不擅长 CPU 密集型任务:如果执行一段非常耗时的同步计算(如巨量数组排序、加密解密、复杂的正则匹配),整个事件循环(Event Loop)会被阻塞,导致所有其他用户的请求都无法被响应。

为了解决这两个问题,Node.js 提供了完善的多进程child_process, cluster)和多线程worker_threads)方案。


多进程 (Multi-process)

多进程架构主要用来解决利用多核 CPU提升服务稳定性的问题。

child_process 模块

Node 提供了四个核心 API 用于创建子进程:

  1. exec:衍生一个 shell 来运行命令,将输出缓存起来后回调返回。适合执行简单的系统命令(输出量小)。
  2. execFile:直接执行可执行文件,不衍生 shell,性能比 exec 高,更安全。
  3. spawn:以流(Stream)的方式实时返回数据。适合处理海量数据或长时间运行的任务。
  4. fork(最高频):专门用来衍生新的 Node.js 进程。它不仅衍生进程,还会在父子进程之间自动建立一条 IPC(进程间通信)通道,可以直接使用 process.send()process.on('message') 进行通信。

Cluster 模块与端口共享机制

在生产环境中部署 Node.js 服务时,通常会使用 Cluster 模块(或者基于它的 PM2)来启动多个进程。

🔥 面试题:Node.js 是如何实现多个进程监听同一个端口的?为什么不会报“端口被占用(EADDRINUSE)”?

这是一个区分度极高的底层面试题。

  • 常规认知:操作系统的网络模型中,一个端口在同一时刻只能被一个进程绑定监听。如果子进程直接 listen(3000),必定报错。
  • Node.js 底层原理
    1. 在 Cluster 模式下,真正监听底层端口的只有主进程(Master),它会在内部创建一个 TCP 服务器。
    2. 子进程(Worker)里面调用的 server.listen(3000) 其实是被 Node.js 拦截和 hack 过的,它并不会真正在底层去调用操作系统的 bind/listen
    3. 主进程接收到外部的网络请求连接后,会根据指定的负载均衡算法(通常是 Round-Robin 轮询),通过 IPC 通道将这个 socket 句柄(Handle)发送给某一个空闲的子进程去处理。
    4. 这种底层机制叫做句柄传递(依赖操作系统的 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 的特点

  1. 共享内存:这是线程相比进程最大的优势!Worker Threads 可以通过 SharedArrayBuffer 实现多线程之间真正的内存共享,极大地避免了进程间通信序列化/反序列化的性能开销。
  2. 独立的执行环境:每个 Worker 线程有自己的 V8 引擎实例、Event Loop 事件循环。但它们同属于一个宿主进程,内存消耗远小于子进程。
  3. 通信机制:线程之间通过 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(多线程)将任务扔到后台去算。”