Node.js 事件循环与异步编程深度解析
从 Libuv 到事件循环六阶段,理解 Node.js 异步 I/O 的底层原理
Node.js 能以单线程处理高并发请求,其核心秘密就藏在事件循环(Event Loop)之中。本文将从底层的 Libuv 架构出发,逐层拆解事件循环的六个阶段,深入理解宏任务与微任务的执行顺序,并探讨集群与多进程如何突破单核瓶颈。
一、Libuv 架构概述
Node.js 的异步 I/O 并非由 V8 引擎完成,而是交由底层的 C 语言库 Libuv 统一调度。Libuv 提供了一个跨平台的异步 I/O 抽象层,是 Node.js 脱离浏览器、走向服务端的基石。
Libuv 的整体架构可以分为以下几个部分:
| 组件 | 职责 | 说明 |
|---|---|---|
| 事件循环 | 驱动异步任务 | 单线程轮询,处理定时器、I/O 回调等 |
| 线程池 | 处理阻塞型任务 | 默认 4 个线程,可配置 |
| I/O 多路复用 | 监听文件描述符 | Linux 使用 epoll,macOS 使用 kqueue,Windows 使用 IOCP |
| 异步 DNS | 非阻塞域名解析 | 利用线程池或系统原生异步接口 |
| 文件系统操作 | 异步文件读写 | 由于平台差异,统一走线程池 |
值得强调的是,Node.js 并非”纯单线程”。JavaScript 代码确实在单线程中执行,但底层的 I/O 操作会由 Libuv 的线程池并行处理。可以通过环境变量调整线程池大小:
# 将线程池大小设置为 8
export UV_THREADPOOL_SIZE=8
const fs = require('fs');
const crypto = require('crypto');
// 文件系统操作和 PBKDF2 都会使用线程池
console.time('total');
Promise.all([
new Promise(resolve => fs.readFile('./large.txt', resolve)),
new Promise(resolve => crypto.pbkdf2('secret', 'salt', 100000, 64, 'sha512', resolve)),
]).then(() => console.timeEnd('total'));
二、事件循环的六个阶段
事件循环可以理解为一个无限循环,每一轮(tick)都会依次经过若干阶段。Node.js 官方将其划分为六个阶段:
┌───────────────────────────┐
│ timers │ ← 执行 setTimeout / setInterval 回调
├───────────────────────────┤
│ pending callbacks │ ← 执行系统级回调(如 TCP 错误)
├───────────────────────────┤
│ idle, prepare │ ← 内部使用
├───────────────────────────┤
│ poll │ ← 获取新 I/O 事件,执行 I/O 回调
├───────────────────────────┤
│ check │ ← 执行 setImmediate 回调
├───────────────────────────┤
│ close │ ← 执行 close 事件回调
└───────────────────────────┘
2.1 timers 阶段
timers 阶段处理所有到期的 setTimeout 和 setInterval 回调。需要注意的是,定时器指定的时间并非精确执行时间,而是最小延迟时间。如果前一个阶段耗时过长,定时器回调会被推迟。
const start = Date.now();
setTimeout(() => {
console.log(`定时器延迟了 ${Date.now() - start - 100}ms 才执行`);
}, 100);
// 模拟一个耗时操作,会拖延 timers 阶段
const heavy = Date.now() + 300;
while (Date.now() < heavy) {}
上述代码中,虽然定时器设置为 100ms,但由于同步代码阻塞了 300ms,回调实际会在 300ms 后才执行。
2.2 pending callbacks 阶段
这个阶段执行某些系统操作的回调,例如 TCP 连接失败时的 ECONNREFUSED 错误回调。日常开发较少直接接触。
2.3 poll 阶段
poll 阶段是事件循环中最核心的部分,它做两件事:
- 计算应该阻塞多久等待 I/O 事件
- 处理 poll 队列中的事件
如果 poll 队列为空,且没有 setImmediate 回调待执行,事件循环会阻塞等待新事件;如果有 setImmediate 待执行,则直接进入 check 阶段。
2.4 check 阶段
check 阶段执行通过 setImmediate 注册的回调。setImmediate 的设计目的是在当前 poll 阶段完成后立即执行。
2.5 close 阶段
close 阶段处理关闭事件的回调,例如 socket 被意外关闭时触发的回调。
const net = require('net');
const server = net.createServer(socket => {
socket.on('close', () => {
console.log('连接已在 close 阶段关闭');
});
});
server.listen(3000);
三、宏任务与微任务
与浏览器类似,Node.js 也区分宏任务和微任务,但 Node.js 的微任务机制有其独特之处。
3.1 微任务队列
Node.js 中有两类微任务:
- Promise 回调:
Promise.then、queueMicrotask - process.nextTick:Node.js 特有,优先级高于 Promise
微任务会在每个阶段切换之间执行。这意味着在一个宏任务回调执行完毕后、进入下一个阶段前,所有微任务都会被清空。
Promise.resolve().then(() => console.log('Promise 微任务'));
process.nextTick(() => console.log('nextTick 微任务'));
console.log('同步代码');
// 输出顺序:
// 同步代码
// nextTick 微任务
// Promise 微任务
3.2 执行顺序对比
setImmediate(() => console.log('setImmediate'));
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));
// 典型输出顺序:
// nextTick
// Promise
// setTimeout / setImmediate(顺序不确定)
四、process.nextTick vs setImmediate
这两个 API 经常被混淆,但它们的执行时机完全不同。
| 特性 | process.nextTick | setImmediate |
|---|---|---|
| 类型 | 微任务 | 宏任务(check 阶段) |
| 执行时机 | 当前阶段结束后立即执行 | 下一轮事件循环的 check 阶段 |
| 优先级 | 高于 Promise | 低于 I/O 回调 |
| 递归风险 | 可能阻塞事件循环 | 不会阻塞,让出控制权 |
4.1 nextTick 的递归陷阱
function recursiveNextTick(n) {
if (n > 0) process.nextTick(() => recursiveNextTick(n - 1));
}
recursiveNextTick(1000000);
console.log('这句会在所有 nextTick 执行前打印');
// 事件循环会被 nextTick 完全阻塞
由于 process.nextTick 的回调在阶段切换时执行,递归调用会不断向微任务队列添加新任务,导致事件循环无法进入下一个阶段。相比之下,setImmediate 递归则会让出控制权:
function recursiveImmediate(n) {
if (n > 0) setImmediate(() => recursiveImmediate(n - 1));
}
recursiveImmediate(1000000);
setInterval(() => console.log('事件循环没有被阻塞'), 100);
五、I/O 多路复用
Libuv 在不同平台上使用不同的 I/O 多路复用机制:
| 平台 | 机制 | 特点 |
|---|---|---|
| Linux | epoll | O(1) 复杂度,支持边缘触发 |
| macOS | kqueue | 类似 epoll,BSD 系特有 |
| Windows | IOCP | 完成端口模型,真正异步 I/O |
| 通用回退 | poll/select | 性能较差,仅做兜底 |
这种抽象让 Node.js 开发者无需关心底层平台差异。无论在哪个系统上,写出的异步代码行为一致:
const http = require('http');
const server = http.createServer((req, res) => {
// 底层由 epoll/kqueue/IOCP 驱动
res.end('Hello World');
});
server.listen(3000, () => {
console.log('服务器启动,I/O 多路复用已就绪');
});
六、集群与多进程
单线程虽然避免了锁的开销,但无法充分利用多核 CPU。Node.js 提供了 cluster 模块来解决这一问题。
6.1 cluster 模块基础
const cluster = require('cluster');
const os = require('os');
const http = require('http');
if (cluster.isPrimary) {
const cpuCount = os.cpus().length;
console.log(`主进程 PID: ${process.pid}`);
console.log(`将启动 ${cpuCount} 个工作进程`);
for (let i = 0; i < cpuCount; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code) => {
console.log(`工作进程 ${worker.process.pid} 退出`);
cluster.fork(); // 自动重启
});
} else {
http.createServer((req, res) => {
res.end(`由进程 ${process.pid} 处理`);
}).listen(3000);
console.log(`工作进程 PID: ${process.pid}`);
}
6.2 负载均衡策略
Node.js 从 v16 起默认使用轮询(round-robin)策略分配连接,避免了早期”惊群效应”的问题。
// 设置调度策略
cluster.schedulingPolicy = cluster.SCHED_RR; // 轮询(默认)
// cluster.schedulingPolicy = cluster.SCHED_NONE; // 由操作系统分配
6.3 进程间通信
if (cluster.isPrimary) {
const worker = cluster.fork();
worker.send({ type: 'task', data: '处理这个任务' });
worker.on('message', msg => {
console.log('收到工作进程消息:', msg);
});
} else {
process.on('message', msg => {
console.log('收到主进程消息:', msg);
process.send({ type: 'result', data: '任务完成' });
});
}
6.4 优雅关闭
process.on('SIGTERM', () => {
console.log('收到终止信号,开始优雅关闭');
server.close(() => {
console.log('所有连接已断开,进程退出');
process.exit(0);
});
// 超时强制退出
setTimeout(() => process.exit(1), 10000);
});
七、实战建议
- CPU 密集型任务:不要在主线程执行,使用
worker_threads或拆分为子进程 - 谨慎使用 process.nextTick:它可能阻塞 I/O,优先用
setImmediate - 合理设置线程池大小:I/O 密集场景可适当增大
UV_THREADPOOL_SIZE - 生产环境使用 cluster:充分利用多核,并实现自动重启
- 监控事件循环延迟:使用
perf_hooks监测事件循环是否被阻塞
const { monitorEventLoopDelay } = require('perf_hooks');
const h = monitorEventLoopDelay();
setInterval(() => {
console.log(`事件循环延迟 - 最小: ${h.min}ms, 最大: ${h.max}ms, 平均: ${h.mean.toFixed(2)}ms`);
h.reset();
}, 5000);
总结
Node.js 的事件循环是其高性能的根基。理解 Libuv 的线程池模型、六阶段执行流程、微任务的优先级规则,能帮助开发者写出更高效、更健壮的异步代码。在生产环境中,配合 cluster 模块与进程管理工具(如 PM2),可以让 Node.js 应用在多核环境下稳定运行,充分发挥异步 I/O 的优势。