Skip to main content

EventLoop

Node.js的**事件循环(Event Loop)**是其异步非阻塞I/O模型的核心机制,负责处理异步操作的回调函数执行顺序。理解它对编写高性能Node.js代码至关重要。以下是详细解析:

一、事件循环的核心作用

  • 异步非阻塞的基石:Node.js主线程单线程执行,但通过事件循环将I/O操作交给底层线程池(如libuv)处理,回调在操作完成后进入事件循环等待执行。
  • 避免阻塞:主线程不会等待I/O操作完成,而是继续执行后续代码,提高并发能力。

二、事件循环的六个阶段(Phase)

事件循环按顺序处理以下阶段,每个阶段处理特定类型的回调:

  1. timers(定时器)

    • 执行setTimeoutsetInterval到期的回调函数。
    • 注意:定时器回调的执行时间可能晚于设定时间(取决于系统负载)。
  2. pending callbacks(待处理回调)

    • 执行系统操作(如TCP错误)的回调。
  3. idle, prepare(内部阶段)

    • 仅供Node.js内部使用,开发者无需关注。
  4. poll(轮询)

    • 核心阶段
      • 获取新的I/O事件(如文件读取、网络请求),执行其回调。
      • 如果没有定时器等待执行,事件循环会在此阶段停留,等待新的I/O事件。
    • 当以下情况发生时,退出poll阶段:
      • 定时器到期,且已到达timers阶段的执行时间。
      • poll队列中的回调执行完毕,且代码中注册了setImmediate
  5. check(检查)

    • 执行setImmediate()的回调。
    • setImmediate设计为在poll阶段后立即执行,常用于异步I/O操作后的回调。
  6. close callbacks(关闭回调)

    • 执行关闭事件的回调(如socket.on('close', ...))。

三、微任务(Microtask)与宏任务(Macrotask)

事件循环中,任务分为两类,执行顺序为:微任务 → 当前阶段宏任务 → 下一阶段

  1. 微任务(Microtask)

    • 包括:Promise.then/catch/finallyprocess.nextTickqueueMicrotask
    • 执行时机:每个阶段结束后,立即执行所有微任务队列中的任务(直到队列为空)。
    • 特殊注意process.nextTick会在当前阶段执行完成后立即执行,优先于其他微任务。
  2. 宏任务(Macrotask)

    • 包括:setTimeoutsetIntervalsetImmediate、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');
});
});

执行结果分析

  1. 主线程setTimeoutsetImmediate被注册。
  2. timers阶段:执行Timeout 1(定时器0ms实际会延迟到约1ms)。
  3. check阶段:执行Immediate 1
  4. I/O回调(poll阶段)readFile完成后,注册的Timeout 2Immediate 2进入队列。
  5. timers阶段:由于I/O回调后可能已错过timers阶段的执行时间,Timeout 2可能在下一轮事件循环执行。
  6. 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

解析

  1. 主线程执行同步代码:StartEnd
  2. 主线程结束后,执行微任务队列:
    • process.nextTick优先执行(NextTick 1)。
    • 然后执行Promise回调(Promise 1Promise 2)。
  3. 进入timers阶段,执行Timeout回调,其内部的process.nextTick在回调完成后立即执行(NextTick 2)。

五、关键结论与注意事项

  1. setTimeoutsetImmediate的执行顺序

    • 在主线程中,两者执行顺序不确定(取决于事件循环的时间开销)。
    • 在I/O回调中,setImmediate总是先于setTimeout执行(因为I/O回调后进入check阶段)。
  2. 微任务的优先级

    • process.nextTick > Promise.then > 其他微任务。
    • 每个阶段切换前,微任务队列会被清空。
  3. 避免阻塞事件循环

    • 长时间的CPU密集型操作会阻塞事件循环,导致所有异步任务无法执行。
    • 解决方案:使用child_processworker_threads处理CPU密集型任务。

六、实践建议

  1. 调试工具

    • 使用console.time()console.timeEnd()测量异步操作耗时。
    • Chrome DevTools的Performance面板可可视化事件循环执行情况。
  2. 性能优化

    • 避免在回调中执行大量同步代码。
    • 使用setImmediate将非紧急任务延迟到下一阶段执行。
  3. 常见误区

    • 认为Node.js完全单线程:实际上,I/O操作由libuv的线程池处理,但事件循环主线程是单线程的。

通过理解事件循环的工作原理,你能更精准地预测异步代码的执行顺序,避免常见的性能陷阱,编写出高效、稳定的Node.js应用。