EventLoop
Node.js的**事件循环(Event Loop)**是其异步非阻塞I/O模型的核心机制,负责处理异步操作的回调函数执行顺序。理解它对编写高性能Node.js代码至关重要。以下是详细解析:
一、事件循环的核心作用
- 异步非阻塞的基石:Node.js主线程单线程执行,但通过事件循环将I/O操作交给底层线程池(如libuv)处理,回调在操作完成后进入事件循环等待执行。
- 避免阻塞:主线程不会等待I/O操作完成,而是继续执行后续代码,提高并发能力。
二、事件循环的六个阶段(Phase)
事件循环按顺序处理以下阶段,每个阶段处理特定类型的回调:
-
timers(定时器)
- 执行
setTimeout
和setInterval
到期的回调函数。 - 注意:定时器回调的执行时间可能晚于设定时间(取决于系统负载)。
- 执行
-
pending callbacks(待处理回调)
- 执行系统操作(如TCP错误)的回调。
-
idle, prepare(内部阶段)
- 仅供Node.js内部使用,开发者无需关注。
-
poll(轮询)
- 核心阶段:
- 获取新的I/O事件(如文件读取、网络请求),执行其回调。
- 如果没有定时器等待执行,事件循环会在此阶段停留,等待新的I/O事件。
- 当以下情况发生时,退出poll阶段:
- 定时器到期,且已到达timers阶段的执行时间。
- poll队列中的回调执行完毕,且代码中注册了
setImmediate
。
- 核心阶段:
-
check(检查)
- 执行
setImmediate()
的回调。 setImmediate
设计为在poll阶段后立即执行,常用于异步I/O操作后的回调。
- 执行
-
close callbacks(关闭回调)
- 执行关闭事件的回调(如
socket.on('close', ...)
)。
- 执行关闭事件的回调(如
三、微任务(Microtask)与宏任务(Macrotask)
事件循环中,任务分为两类,执行顺序为:微任务 → 当前阶段宏任务 → 下一阶段。
-
微任务(Microtask)
- 包括:
Promise.then/catch/finally
、process.nextTick
、queueMicrotask
。 - 执行时机:每个阶段结束后,立即执行所有微任务队列中的任务(直到队列为空)。
- 特殊注意:
process.nextTick
会在当前阶段执行完成后立即执行,优先于其他微任务。
- 包括:
-
宏任务(Macrotask)
- 包括:
setTimeout
、setInterval
、setImmediate
、I/O回调。 - 执行时机:按事件循环的阶段顺序执行。
- 包括:
四、执行顺序示例
理解事件循环的关键是通过示例分析执行流程。以下是几个经典场景:
示例1:定时器与setImmediate的顺序
setTimeout(() => {
console.log('Timeout 1');
}, 0);
setImmediate(() => {
console.log('Immediate 1');
});
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('Timeout 2');
}, 0);
setImmediate(() => {
console.log('Immediate 2');
});
});
执行结果分析:
- 主线程:
setTimeout
和setImmediate
被注册。 - timers阶段:执行
Timeout 1
(定时器0ms实际会延迟到约1ms)。 - check阶段:执行
Immediate 1
。 - I/O回调(poll阶段):
readFile
完成后,注册的Timeout 2
和Immediate 2
进入队列。 - timers阶段:由于I/O回调后可能已错过timers阶段的执行时间,
Timeout 2
可能在下一轮事件循环执行。 - check阶段:
Immediate 2
优先执行(在I/O回调后,setImmediate
总是在setTimeout
前执行)。
示例2:微任务与宏任务的执行顺序
console.log('Start');
Promise.resolve().then(() => {
console.log('Promise 1');
});
process.nextTick(() => {
console.log('NextTick 1');
});
setTimeout(() => {
console.log('Timeout');
process.nextTick(() => {
console.log('NextTick 2');
});
}, 0);
Promise.resolve().then(() => {
console.log('Promise 2');
});
console.log('End');
执行结果:
Start
End
NextTick 1
Promise 1
Promise 2
Timeout
NextTick 2
解析:
- 主线程执行同步代码:
Start
→End
。 - 主线程结束后,执行微任务队列:
process.nextTick
优先执行(NextTick 1
)。- 然后执行Promise回调(
Promise 1
→Promise 2
)。
- 进入timers阶段,执行
Timeout
回调,其内部的process.nextTick
在回调完成后立即执行(NextTick 2
)。
五、关键结论与注意事项
-
setTimeout
与setImmediate
的执行顺序- 在主线程中,两者执行顺序不确定(取决于事件循环的时间开销)。
- 在I/O回调中,
setImmediate
总是先于setTimeout
执行(因为I/O回调后进入check阶段)。
-
微任务的优先级
process.nextTick
>Promise.then
> 其他微任务。- 每个阶段切换前,微任务队列会被清空。
-
避免阻塞事件循环
- 长时间的CPU密集型操作会阻塞事件循环,导致所有异步任务无法执行。
- 解决方案:使用
child_process
或worker_threads
处理CPU密集型任务。
六、实践建议
-
调试工具
- 使用
console.time()
和console.timeEnd()
测量异步操作耗时。 - Chrome DevTools的
Performance
面板可可视化事件循环执行情况。
- 使用
-
性能优化
- 避免在回调中执行大量同步代码。
- 使用
setImmediate
将非紧急任务延迟到下一阶段执行。
-
常见误区
- 认为Node.js完全单线程:实际上,I/O操作由libuv的线程池处理,但事件循环主线程是单线程的。
通过理解事件循环的工作原理,你能更精准地预测异步代码的执行顺序,避免常见的性能陷阱,编写出高效、稳定的Node.js应用。