多进程与多线程
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 只是拿到了主进程丢过来的句柄去处理请求。
IPC 通信原理
面试题:Node.js 父子进程是怎么通信的?
- Node.js 的 IPC(Inter-Process Communication)底层是通过 Libuv 实现的。
- 在 Windows 下基于 命名管道 (Named Pipe) 实现。
- 在 *nix (Linux/macOS) 下基于 Unix Domain Socket 实现。
- 表现形式上:父子进程之间建立双向通信通道,传递的数据会被
JSON.stringify序列化,另一端收到后再JSON.parse反序列化(因此无法传递函数或不可序列化的对象)。
多线程 (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);
});
}
面试总结:多进程 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(多线程)将任务扔到后台去算。”