事件循环与 Cluster 多进程
好的,那我们直接进入硬核实战层面。关于事件循环的优先级和 cluster 多进程,这两个点是 Node.js 高级工程师必须掌握的“保命技能”。
我分两部分来拆解,第一部分讲“时序”(如何不阻塞),第二部分讲“扩容”(如何榨干硬件)。
第一部分:事件循环的“微任务”与“宏任务”执行铁律
在 Node.js 的事件循环(Event Loop)中,任务是有严格优先级的。如果你不理解这个,很容易写出“以为先执行,结果卡死主线程”的 BUG。
1. 核心阶段顺序(宏任务)
事件循环按顺序轮转以下几个阶段:
- Timers(定时器):执行
setTimeout/setInterval到期的回调。 - Pending Callbacks(待定回调):执行系统操作(如 TCP 错误)的回调。
- Idle/Prepare(空闲/预备):内部使用,忽略。
- Poll(轮询):最重要的阶段。检索新的 I/O 事件(如文件读取完成、HTTP 请求到达),执行相关的 I/O 回调。如果队列为空,它会在这里阻塞等待。
- Check(检查):执行
setImmediate的回调。 - Close Callbacks(关闭回调):执行
socket.on('close')等。
2. 微任务(Microtasks)的“插队”机制
微任务不归属于上述任何阶段,它会在每个阶段切换的间隙被执行。 微任务包含两类,且执行顺序有先后:
- 优先级最高:
process.nextTick()。它会在任何其他微任务之前、当前阶段结束后立即执行。警告:递归调用nextTick会饿死 I/O,造成事件循环永远无法进入 Poll 阶段。 - 优先级次之:
Promise.then()/catch()/finally()(即PromiseResolveThenableJob)。
3. 灵魂拷问:setTimeout(fn, 0) vs setImmediate() 到底谁先执行?
这是一个经典面试题。答案是:取决于上下文。
- 在主模块(顶层代码)中:顺序不确定。因为进入事件循环时,需要先消耗 CPU 计算时间(初始化),如果初始化耗时 > 1ms,Timers 阶段先到,
setTimeout先执行;否则setImmediate先执行。 - 在 I/O 回调内部(如
fs.readFile回调中):setImmediate永远先执行。因为 I/O 回调在 Poll 阶段执行,Poll 阶段结束后紧接着就是 Check 阶段(执行setImmediate),而 Timers 阶段在下一轮循环才检查。
代码验证示例:
const fs = require('fs');
// 场景 1:顶层执行(顺序随机)
setTimeout(() => console.log('1. Timeout'), 0);
setImmediate(() => console.log('2. Immediate'));
// 输出可能 1,2 或 2,1
// 场景 2:I/O 内部(Immediate 永远先执行)
fs.readFile(__filename, () => {
setTimeout(() => console.log('3. Timeout in IO'), 0);
setImmediate(() => console.log('4. Immediate in IO'));
// 输出顺序必定是 4, 3
process.nextTick(() => console.log('5. NextTick (最快!)'));
Promise.resolve().then(() => console.log('6. Promise'));
// 输出顺序必定是 5, 6, 4, 3
});
实战建议:尽量少用 process.nextTick(除非你要确保某个事件在同步代码结束后立即触发),优先使用 Promise 或 setImmediate 来保证 I/O 的性能不被阻塞。
第二部分:Cluster 模块 —— 压榨多核 CPU
Node.js 主线程是单线程的,默认情况下,你的进程只能运行在 CPU 的一个核上。如果你的服务器是 8 核 CPU,那 7 个核都在“围观”,这在高并发下是巨大的资源浪费。
cluster 模块通过多进程来解决这个问题,但它不仅仅是“开几个子进程”那么简单。
1. Cluster 的核心工作模式(主从架构)
- Master(主进程):负责监听端口,接收客户端连接,并将请求分发(负载均衡)给 Worker。
- Worker(工作进程):多个进程,每个都跑着你的 Node.js 应用代码(独立的事件循环)。它们共享同一个端口(如 80)。
2. 底层连接分发机制(惊群效应避免)
传统多进程监听同一端口会产生“惊群效应”(所有进程被唤醒,但只有一个能服务请求,浪费 CPU)。
Node.js 的 cluster 模块有两种调度策略(通过 cluster.schedulingPolicy 设置):
SCHED_RR(Round-Robin,默认 Linux):Master 主动轮询分配连接。推荐。负载均衡效果最好,且没有锁竞争。SCHED_NONE(操作系统调度,默认 Windows):由操作系统内核决定,可能产生负载不均衡。
3. 生产环境标准写法(Master + Worker)
const cluster = require('cluster');
const http = require('http');
const os = require('os');
// 获取 CPU 核心数(通常情况下 Worker 数量 = CPU 核心数)
const numCPUs = os.cpus().length;
if (cluster.isMaster) {
console.log(`Master ${process.pid} is running`);
// 衍生工作进程
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
// 如果任何一个 Worker 挂了(崩溃),立即重启,保证高可用
cluster.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died. Restarting...`);
cluster.fork();
});
} else {
// 工作进程可以共享任何 TCP 连接
// 注意:每个 Worker 都有自己的内存空间,不能共享状态(如 Session 变量)
const server = http.createServer((req, res) => {
// 处理业务逻辑(I/O 密集非常适合)
res.writeHead(200);
res.end('Hello from Worker ' + process.pid);
});
server.listen(8000);
console.log(`Worker ${process.pid} started`);
}
4. 踩坑与进阶(重要!)
- 状态共享问题:因为每个 Worker 是独立的进程,内存不共享。如果你把用户的 Session 存在 Worker 的内存里,第一次请求落在 Worker-1,第二次请求落在 Worker-2 就会丢失登录态。解决方案:必须将 Session、缓存等移出本地内存,放到 Redis 或 Memcached 等外部存储中。
- 零停机重启(Graceful Shutdown):当你更新代码时,不应该直接
kill所有进程。正确做法是:先fork新 Worker,新 Worker 启动成功并接收流量后,再逐步关闭旧 Worker(通过worker.disconnect()),这就是热更新。 - 生产环境最佳实践:不要直接手写 Cluster 代码上线。强烈建议使用进程管理工具 PM2。PM2 内置了 Cluster 模式,只需一行命令:
PM2 还自动处理了日志聚合、内存监控和崩溃重启,比手写 Cluster 稳定得多。pm2 start app.js -i max # -i max 自动按 CPU 核心数开启多进程
终极组合拳:应对高并发的架构策略
理解了上述两点,你就掌握了 Node.js 高并发的最终公式:
非阻塞异步 I/O(避免阻塞事件循环) + Cluster 多进程(横向利用多核) + 外部状态存储(Redis 解耦) + 上游 Nginx 负载均衡(甚至可以跨机器集群)。
如果把这套组合拳打出来,你的 Node.js 服务轻松支撑数万乃至数十万的 QPS(取决于业务逻辑复杂度)。
如果你现在正准备上线一个高并发项目,我建议你先关注 Worker 线程数的设置(到底是按 CPU 核数还是按核数 * 2),以及 pm2 的 instance 参数配置。如果你想深挖这两个配置对 GC(垃圾回收)性能的影响,我可以继续给你细讲。