深入理解 Node.js 事件循环
一句话概述:六个阶段、微任务宏任务、libuv 线程池、nextTick vs setImmediate——掌握 Node.js 异步执行的核心机制
什么是事件循环?
定义:事件循环是 Node.js 处理非阻塞 I/O 操作的核心机制,尽管 JavaScript 是单线程的,但通过将操作卸载到系统内核(通过 libuv),Node.js 可以执行非阻塞操作。
涉及场景:
- 异步 I/O:文件读写、网络请求、数据库查询
- 定时器:
setTimeout、setInterval - 事件处理:HTTP 请求、WebSocket 消息
- 进程通信:IPC、子进程
作用:
- 协调异步操作的执行顺序
- 避免阻塞主线程
- 实现高并发处理
一、事件循环的六个阶段
完整流程图
┌───────────────────────────┐
┌─>│ timers │ 执行 setTimeout/setInterval 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ 执行延迟到下一轮的 I/O 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ 仅内部使用
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │ 执行 setImmediate 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │ 执行 close 事件回调(如 socket.on('close'))
└───────────────────────────┘各阶段详解
1. timers 阶段
执行 setTimeout() 和 setInterval() 的回调。
setTimeout(() => {
console.log('timeout');
}, 0);
// 注意:延迟 0ms 不代表立即执行,而是在 timers 阶段执行
// 实际延迟受事件循环当前阶段影响关键点:
- 定时器回调的执行时间不是精确的,取决于事件循环的繁忙程度
- 最小延迟约 1ms(操作系统限制)
2. pending callbacks 阶段
执行某些系统操作的回调,如 TCP 错误。大多数情况下可以忽略此阶段。
3. idle, prepare 阶段
仅供 libuv 内部使用,开发者无需关心。
4. poll 阶段(最重要)
作用:
- 检索新的 I/O 事件
- 执行 I/O 相关的回调(几乎所有回调,除了 close、timers、setImmediate)
- 如果没有其他任务,Node.js 会在此阶段阻塞等待
执行逻辑:
poll 阶段:
1. 如果 poll 队列不为空
→ 同步执行队列中的回调,直到队列清空或达到系统限制
2. 如果 poll 队列为空
→ 检查是否有 setImmediate 回调
- 有 → 进入 check 阶段
- 无 → 检查是否有到期的 timers
- 有 → 回到 timers 阶段
- 无 → 阻塞等待新的 I/O 事件5. check 阶段
执行 setImmediate() 的回调。
setImmediate(() => {
console.log('immediate');
});为什么需要 setImmediate?
- 允许在 poll 阶段完成后立即执行回调
- 比
setTimeout(fn, 0)更可预测
6. close callbacks 阶段
执行关闭事件的回调,如 socket.on('close', ...)。
const net = require('net');
const server = net.createServer();
server.on('close', () => {
console.log('服务器已关闭'); // 在 close callbacks 阶段执行
});
server.close();二、微任务与宏任务
任务分类
微任务(Microtask):
process.nextTick()Promise.then/catch/finallyqueueMicrotask()
宏任务(Macrotask):
setTimeoutsetIntervalsetImmediate- I/O 操作
执行顺序
每个事件循环阶段之间:
1. 清空 nextTick 队列
2. 清空微任务队列(Promise)
3. 进入下一个阶段关键规则:
process.nextTick()优先级最高,在任何阶段切换前执行- 微任务在每个宏任务之间执行
- 过度使用
nextTick可能导致事件循环饥饿
完整示例
console.log('1: 同步代码');
setTimeout(() => {
console.log('2: setTimeout');
Promise.resolve().then(() => console.log('3: Promise in setTimeout'));
}, 0);
setImmediate(() => {
console.log('4: setImmediate');
});
Promise.resolve().then(() => {
console.log('5: Promise');
process.nextTick(() => console.log('6: nextTick in Promise'));
});
process.nextTick(() => {
console.log('7: nextTick');
Promise.resolve().then(() => console.log('8: Promise in nextTick'));
});
console.log('9: 同步代码结束');
// 输出顺序:
// 1: 同步代码
// 9: 同步代码结束
// 7: nextTick(nextTick 队列清空)
// 8: Promise in nextTick(微任务队列清空)
// 5: Promise
// 6: nextTick in Promise
// 2: setTimeout(timers 阶段)
// 3: Promise in setTimeout
// 4: setImmediate(check 阶段)三、process.nextTick vs setImmediate
核心区别
| 特性 | process.nextTick() | setImmediate() |
|---|---|---|
| 执行时机 | 当前操作完成后立即执行 | check 阶段执行 |
| 优先级 | 最高(在所有阶段切换前) | 低于 nextTick 和微任务 |
| 递归风险 | 可能导致事件循环饥饿 | 不会阻塞事件循环 |
| 适用场景 | 错误处理、API 一致性 | I/O 操作后的回调 |
事件循环饥饿示例
// ❌ 危险:nextTick 递归会阻塞事件循环
function recursiveNextTick() {
process.nextTick(recursiveNextTick);
}
recursiveNextTick();
// 此时 setTimeout、setImmediate、I/O 回调都无法执行
// 因为 nextTick 队列永远不为空
// ✅ 安全:setImmediate 不会阻塞
function recursiveImmediate() {
setImmediate(recursiveImmediate);
}
recursiveImmediate();
// 每次循环只执行一个 setImmediate,其他任务有机会执行使用建议
使用 process.nextTick():
- 在构造函数中延迟事件发射(保证监听器已注册)
- 错误优先处理
- 确保 API 始终异步
const EventEmitter = require('events');
class MyEmitter extends EventEmitter {
constructor() {
super();
// ✅ 使用 nextTick 确保事件在监听器注册后发射
process.nextTick(() => {
this.emit('ready');
});
}
}
const emitter = new MyEmitter();
emitter.on('ready', () => console.log('准备就绪')); // 能正常接收使用 setImmediate():
- I/O 操作后的回调
- 拆分 CPU 密集型任务
// 拆分大任务
function processLargeArray(array, callback) {
const chunk = array.splice(0, 1000);
// 处理当前块
chunk.forEach(item => process(item));
if (array.length > 0) {
setImmediate(() => processLargeArray(array, callback));
} else {
callback();
}
}四、setTimeout vs setImmediate 执行顺序
不确定性
在主模块中,两者的执行顺序是不确定的:
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 可能输出:
// timeout
// immediate
// 也可能输出:
// immediate
// timeout原因:
- 事件循环启动时,如果进入 timers 阶段时定时器已到期 →
setTimeout先执行 - 如果定时器未到期(受系统调度影响)→ 直接进入 poll 阶段 →
setImmediate先执行
确定性
在 I/O 回调中,setImmediate 总是先于 setTimeout 执行:
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// 输出顺序始终是:
// immediate
// timeout原因:I/O 回调在 poll 阶段执行,poll 阶段之后是 check 阶段(setImmediate),然后才回到 timers 阶段。
五、libuv 与线程池
libuv 架构
Node.js 的异步 I/O 由 libuv 库实现:
┌─────────────────────────────────────────┐
│ JavaScript (V8) │
├─────────────────────────────────────────┤
│ Node.js Bindings │
├─────────────────────────────────────────┤
│ libuv │
│ ┌───────────────┬──────────────────┐ │
│ │ Event Loop │ Thread Pool │ │
│ │ (单线程) │ (默认 4 线程) │ │
│ └───────────────┴──────────────────┘ │
├─────────────────────────────────────────┤
│ 操作系统 (epoll/kqueue/IOCP) │
└─────────────────────────────────────────┘线程池任务
以下操作在 libuv 线程池中执行(默认 4 个线程):
- 文件 I/O(
fs.*) - DNS 查询(
dns.lookup) - 加密操作(
crypto.pbkdf2、crypto.randomBytes) - Zlib 压缩
const crypto = require('crypto');
// 这些操作在线程池中并行执行
for (let i = 0; i < 4; i++) {
crypto.pbkdf2('password', 'salt', 100000, 64, 'sha512', (err, key) => {
console.log(`任务 ${i} 完成`);
});
}
// 如果有 5 个任务,第 5 个会等待前 4 个中的一个完成调整线程池大小
# 设置线程池大小为 8
UV_THREADPOOL_SIZE=8 node app.js// 或在代码中设置(必须在任何 libuv 操作之前)
process.env.UV_THREADPOOL_SIZE = 8;注意:
- 网络 I/O 不使用线程池,直接使用操作系统的异步机制(epoll/kqueue)
- 线程池大小不是越大越好,过多线程会增加上下文切换开销
六、实战:诊断事件循环问题
检测事件循环阻塞
const { performance } = require('perf_hooks');
let lastCheck = performance.now();
setInterval(() => {
const now = performance.now();
const delay = now - lastCheck - 1000; // 减去预期的 1000ms
if (delay > 100) {
console.warn(`事件循环延迟: ${delay.toFixed(2)}ms`);
}
lastCheck = now;
}, 1000);
// 模拟阻塞
setTimeout(() => {
const start = Date.now();
while (Date.now() - start < 2000) {} // 阻塞 2 秒
}, 3000);使用 clinic.js 诊断
# 安装
npm install -g clinic
# 诊断事件循环延迟
clinic doctor -- node app.js
# 诊断 CPU 使用
clinic flame -- node app.js
# 诊断内存泄漏
clinic heapprofiler -- node app.js面试高频题
1. Node.js 事件循环的六个阶段分别是什么?
答案:timers(定时器)、pending callbacks(待定回调)、idle/prepare(内部使用)、poll(轮询,最重要)、check(setImmediate)、close callbacks(关闭回调)。poll 阶段负责执行 I/O 回调,如果队列为空会检查 setImmediate 或等待新事件。
2. process.nextTick() 和 setImmediate() 有什么区别?
答案:nextTick 在当前操作完成后、事件循环继续前立即执行,优先级最高但可能导致事件循环饥饿。setImmediate 在 check 阶段执行,不会阻塞事件循环。在 I/O 回调中 setImmediate 总是先于 setTimeout 执行,但在主模块中顺序不确定。
3. 为什么 setTimeout(fn, 0) 和 setImmediate(fn) 的执行顺序不确定?
答案:在主模块中,事件循环启动时如果定时器已到期则先执行 setTimeout,否则直接进入 poll 阶段后执行 setImmediate。这取决于进程启动速度和系统调度。但在 I/O 回调中,setImmediate 总是先执行,因为 I/O 回调在 poll 阶段,之后就是 check 阶段。
4. libuv 线程池用于哪些操作?
答案:文件 I/O、DNS 查询(dns.lookup)、加密操作(crypto.pbkdf2)、Zlib 压缩。网络 I/O 不使用线程池,直接使用操作系统的异步机制(Linux 的 epoll、macOS 的 kqueue、Windows 的 IOCP)。默认线程池大小为 4,可通过 UV_THREADPOOL_SIZE 环境变量调整。
5. 如何避免事件循环阻塞?
答案:(1)避免同步阻塞操作(如 fs.readFileSync);(2)拆分 CPU 密集型任务,使用 setImmediate 让出事件循环;(3)CPU 密集型任务使用 Worker Threads;(4)使用 Stream 处理大数据;(5)监控事件循环延迟,使用 clinic.js 等工具诊断性能瓶颈。