Skip to content

事件循环

Node.js 的事件循环(Event Loop)是理解 Node.js 异步编程的核心。很多人知道它和“非阻塞 I/O”有关,但真正容易混淆的地方有三个:

  1. 为什么事件循环要分成 6 个阶段;
  2. setTimeoutsetImmediateprocess.nextTick、Promise 到底谁先执行;
  3. Node.js 事件循环和浏览器事件循环到底有什么区别。

这篇笔记就专门讲清这三件事。


1. 事件循环到底在解决什么问题

Node.js 的 JavaScript 主线程一次只能执行一段代码。

如果遇到这些耗时操作时主线程原地等待:

  • 文件读写;
  • 网络请求;
  • 数据库访问;
  • DNS 查询;
  • 定时器等待;

那么整个进程的并发能力会非常差。

所以 Node.js 的做法是:

  • 同步代码照常在主线程执行;
  • 遇到异步任务先交给底层;
  • 等任务完成后,把对应回调安排到合适的位置;
  • 主线程通过事件循环不断取出这些回调执行。

也就是说:

> 事件循环不是“让异步任务自己执行”,而是“决定异步任务完成后,回调何时回到主线程执行”。


2. 为什么会有 6 个阶段

Node.js 的事件循环基于 libuv,一轮循环里主要会经历 6 个 phase(阶段):

  1. timers
  2. pending callbacks
  3. idle, prepare
  4. poll
  5. check
  6. close callbacks

之所以要分阶段,而不是把所有回调扔进一个大队列,是因为:

2.1 不同任务的“就绪条件”不同

例如:

  • setTimeout(fn, 1000):需要等时间到了;
  • 文件读取回调:需要等 I/O 完成;
  • setImmediate(fn):希望在当前轮 poll 之后尽快执行;
  • close 回调:要在资源关闭时触发。

这些任务天然不是一类。

2.2 需要稳定的执行顺序

Node.js 必须给不同类型任务明确的调度语义,例如:

  • 定时器在什么时候检查;
  • I/O 回调主要在哪一类阶段执行;
  • setImmediate 为什么总是在 check
  • 关闭回调为什么要单独处理。

2.3 要兼顾效率和公平性

如果所有任务混在一个大队列里:

  • 某类任务可能长期霸占循环;
  • 执行顺序会变得很难预测;
  • 也不利于和底层操作系统 I/O 模型对接。

所以分阶段的本质是:

> 按任务类型和触发时机分层调度。


3. 6 个阶段分别做什么

3.1 timers

负责执行已经到期的:

  • setTimeout
  • setInterval

注意:

  • 这里不是“时间一到立刻执行”;
  • 而是“到期之后,最早在后续某次 timers 阶段执行”。

所以:

js
setTimeout(fn, 0)

并不等于“马上执行”,只是表示它会尽早进入 timers 阶段候选。


3.2 pending callbacks

负责执行某些被延后到下一轮的系统级回调。

这类内容平时业务代码接触不多,可以先粗略理解成:

  • 一些底层 I/O 错误回调;
  • 某些系统层面的收尾处理;
  • 不适合直接在前一轮立刻执行的回调。

3.3 idle, prepare

这是 libuv 内部使用的阶段。

对业务开发来说,通常只需要知道:

  • 它们存在;
  • 主要服务于内部调度;
  • 不是开发者主动投放任务的地方。

3.4 poll

这是最核心的阶段。

它主要做两件事:

  1. 执行 I/O 相关回调;
  2. 在必要时等待新的 I/O 事件到来。

常见的 I/O 回调,如:

  • 文件读取完成;
  • 网络数据返回;
  • socket 收到数据;
  • 某些数据库客户端的底层网络事件;

很多都会在这里进入执行。

如果 poll 队列空了,事件循环会根据情况决定:

  • 如果有 setImmediate,则继续进入 check
  • 如果没有,而又暂时没有别的事情要做,可能在这里阻塞等待 I/O;
  • 如果有即将到期的定时器,则准备进入下一轮定时器检查。

3.5 check

专门负责执行:

  • setImmediate()

所以 setImmediate 的语义不是“比所有东西都立即”,而是:

> 在本轮 poll 之后,尽快于 check 阶段执行。


3.6 close callbacks

负责执行资源关闭相关的回调,例如:

  • socket.on('close', ...)

它之所以单独存在,是因为“资源关闭”是一类特殊生命周期动作,和普通 I/O 回调分开更清晰。


4. 一张顺序图先记住整体结构

一轮事件循环可以先粗略记成:

text
timers
  -> pending callbacks
  -> idle, prepare
  -> poll
  -> check
  -> close callbacks
  -> 下一轮

但这里必须立刻补一句:

> process.nextTick 和 Promise 微任务,不属于这 6 个阶段。

它们优先级更高,也更容易造成理解混乱。


5. process.nextTick 和 Promise 为什么不属于这 6 个阶段

Node.js 中常见的“高优先级小队列”有两类:

  1. process.nextTick 队列
  2. Promise 微任务队列(如 Promise.thenqueueMicrotask

它们不是 libuv 那 6 个 phase 的一部分。

更准确地说:

  • process.nextTick 是 Node 自己维护的特殊队列;
  • Promise 微任务主要遵循 V8 的微任务机制;
  • Node 会在合适时机先清空这些队列,再继续推进事件循环。

常见记忆规则:

> 同步代码 -> process.nextTick -> Promise 微任务 -> 事件循环 phase 中的任务

例如:

js
console.log('start');

setTimeout(() => console.log('timeout'), 0);

process.nextTick(() => console.log('nextTick'));

Promise.resolve().then(() => console.log('promise'));

console.log('end');

输出通常是:

js
start
end
nextTick
promise
timeout

这里要点有两个:

  • nextTick 比 Promise 更早;
  • Promise 比 setTimeout 更早。

6. 四个最容易混淆的 API:谁先执行

要讲清执行顺序,先把它们归类:

6.1 process.nextTick(fn)

  • 不属于 6 个阶段;
  • 优先级最高;
  • 当前这段 JS 执行结束后尽快执行。

6.2 Promise.resolve().then(fn)

  • 属于微任务;
  • 不属于 6 个阶段;
  • nextTick 之后、宏任务之前执行。

6.3 setTimeout(fn, 0)

  • 属于 timers 阶段;
  • 不是“马上执行”;
  • 是“最早在后续某轮 timers 执行”。

6.4 setImmediate(fn)

  • 属于 check 阶段;
  • 语义是“当前轮 poll 后尽快执行”。

7. 一个经典例子:基础优先级

js
console.log('1');

setTimeout(() => console.log('2'), 0);
setImmediate(() => console.log('3'));
process.nextTick(() => console.log('4'));
Promise.resolve().then(() => console.log('5'));

console.log('6');

稳定部分通常是:

js
1
6
4
5
23
32

这里稳定的是:

  • 同步代码先执行:16
  • process.nextTick 再执行:4
  • Promise 微任务再执行:5

不稳定的是:

  • setTimeout(..., 0)setImmediate() 谁先。

8. 为什么 setTimeout(0)setImmediate() 谁先不固定

这是 Node.js 里最容易被误解的一点。

8.1 在顶层代码中同时注册时:先后不一定

js
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

这时输出可能是:

js
timeout
immediate

也可能是:

js
immediate
timeout

原因不是 Node “随机执行”,而是:

  • setTimeout(..., 0) 进入 timers
  • setImmediate() 进入 check
  • 它们处在不同 phase;
  • 顶层代码结束后,第一次进入事件循环时,哪个 phase 先拿到“已可执行任务”,会受运行时调度影响。

所以更准确的说法是:

> 在顶层代码中,不应依赖 setTimeout(0)setImmediate() 的绝对先后顺序。


8.2 在 I/O 回调中注册时:通常 setImmediate 先执行

js
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});

这里通常输出:

js
immediate
timeout

原因是:

  • fs.readFile 的回调通常在 poll 阶段执行;
  • poll 回调里注册 setImmediate,它会进入本轮后面的 check
  • 在同一个回调里注册 setTimeout(..., 0),它通常要等下一轮 timers

所以在这个上下文里,setImmediate 更可预测。


9. 为什么 process.nextTick 比 Promise 更早

例如:

js
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));

输出通常是:

js
nextTick
promise

原因是 Node 在继续事件循环之前,会先处理两个更高优先级的小队列,而顺序通常是:

  1. 清空 process.nextTick 队列;
  2. 再清空 Promise 微任务队列。

所以:

> 在 Node.js 中,process.nextTick 的优先级高于 Promise 微任务。

这也是 Node 和浏览器一个很重要的差异,因为浏览器根本没有 process.nextTick


10. 为什么不能滥用 process.nextTick

因为它优先级太高。 例如:

js
function loop() {
  process.nextTick(loop);
}

loop();

这种写法会不断把任务塞回 nextTick 队列,导致:

  • 事件循环迟迟进不到后续 phase;
  • 定时器和 I/O 得不到处理;
  • 整个进程像“卡死”一样。

这类现象通常叫做:

  • 饿死事件循环;
  • starving the event loop。

所以 process.nextTick 适合做:

  • 少量、短小、立即性的补偿逻辑;
  • 错误抛出时机调整;
  • API 行为对齐。

不适合:

  • 递归调度;
  • 大批量任务排队;
  • 当成“更快的 setTimeout”。

11. Node.js 和浏览器事件循环的相同点

11.1 都是为单线程异步调度服务

二者的目标都一样:

  • 主线程一次只执行一段 JS;
  • 异步任务完成后,回调在未来某个时机回到主线程执行;
  • 通过队列和调度规则避免阻塞主线程。

11.2 都有“同步代码优先,微任务次之”的特点

无论是 Node 还是浏览器:

  • 同步代码先跑;
  • 微任务优先级高于一般异步任务;
  • Promise 都属于微任务体系的一部分。

11.3 都不意味着“异步代码会并行执行 JS”

异步只表示:

  • 等待过程不阻塞当前线程;
  • 真正执行回调时,仍然要回到主线程排队。

12. Node.js 和浏览器事件循环的不同点

12.1 Node.js 是“phase 模型”,浏览器更常讲“任务 + 微任务 + 渲染”

Node.js 常见描述是:

  • timers
  • pending callbacks
  • poll
  • check
  • ...

浏览器更常见描述是:

  • 执行一个任务(task / 宏任务)
  • 清空微任务(microtask)
  • 必要时渲染页面
  • 再进入下一轮

所以:

> Node.js 更强调 I/O phase 调度;浏览器更强调任务、微任务和渲染时机。

12.2 浏览器有渲染流程,Node.js 没有

浏览器事件循环必须考虑:

  • DOM 更新;
  • 样式计算;
  • layout;
  • paint;
  • requestAnimationFrame

Node.js 没有页面渲染,所以不会有这套调度语义。

12.3 Node.js 有 process.nextTick,浏览器没有

这是非常关键的区别。

浏览器常见的是:

  • 宏任务;
  • 微任务。

Node.js 则多了一层:

  • process.nextTick
  • Promise 微任务
  • phase 中的任务

12.4 setImmediate 在 Node.js 里有明确位置

在 Node.js 中:

  • setImmediate() 对应 check 阶段。

浏览器里一般不把它当成标准主流 API 来讨论。


13. 最容易考的结论

13.1 6 个阶段顺序

text
timers
-> pending callbacks
-> idle, prepare
-> poll
-> check
-> close callbacks

13.2 四类任务的粗略优先级

text
同步代码
-> process.nextTick
-> Promise 微任务
-> timers / poll / check 等阶段任务

13.3 两条高频结论

  • process.nextTick 比 Promise 更早;
  • 在 I/O 回调中,setImmediate 通常比 setTimeout(..., 0) 更早。

13.4 一条不要死记的结论

  • 在顶层代码中,setTimeout(..., 0)setImmediate() 先后不可靠

14. 最终总结

> Node.js 事件循环本质上是 libuv 提供的一套分阶段调度机制。之所以要分成 6 个阶段,是因为定时器、I/O、立即执行回调、关闭回调等任务的触发条件不同,需要在不同时间点处理。 > > 在学习执行顺序时,最重要的是区分三层东西: > > 1. 同步代码; > 2. process.nextTick 和 Promise 微任务; > 3. 事件循环 6 个 phase 中的任务。 > > 只要把这三层分清楚,再结合“setTimeout 属于 timerssetImmediate 属于 check、I/O 多发生在 poll”这三个锚点,Node.js 事件循环就不会再显得混乱。

评论区

欢迎留言、补充或勘误。

xiaoba.blog