跳到主要内容

Node 事件循环流程怎么讲:从启动脚本到一轮轮处理异步任务

面试速答(30 秒版 TL;DR)

  • Node 的事件循环本质上是 libuv 驱动的一轮轮调度循环,不是“只有一个回调队列”那么简单。
  • 一轮循环里,Node 会按阶段处理不同来源的任务,常见口径是:timerspending callbacksidle, preparepollcheckclose callbacks
  • 真正决定“这一轮要不要继续跑、在哪儿阻塞等待”的核心阶段是 poll,因为 I/O 结果大多会在这里被取出并分发。
  • process.nextTick 不属于事件循环 phase;它会在当前 JS 操作结束后优先清空。Promise 微任务也会在合适时机清空,但通常晚于 nextTick
  • 从 Node 20 开始,官方文档特别提示:timer 的执行时机和更早版本有差异,讲原理时要优先抓“阶段职责”和“调度关系”,不要死背所有输出顺序。

先把问题讲对:Node 为什么能做异步 I/O

Node 执行 JavaScript 时,主线程一次只能跑一段同步代码,但异步 I/O 并不是主线程自己“后台偷偷做完”的。

更准确的说法是:

  1. JS 线程负责执行业务代码和回调。
  2. libuv 负责维护事件循环、定时器、I/O 观察器、线程池任务完成通知等基础设施。
  3. 操作系统内核负责网络 socket、文件描述符等底层事件通知。
  4. 当 I/O 条件满足,内核把“有事件发生了”这件事通知给 libuv,libuv 再把对应回调安排到后续可执行的位置。

一句话:

  • Node 的异步,不是 JS 自己并发执行,而是“JS 主线程 + libuv + 内核事件通知”协作出来的。

心智模型:事件循环不是一个队列,而是一套分阶段调度系统

很多人会把事件循环讲成:

  • “调用栈空了,就从队列里拿回调执行。”

这个说法不算错,但对 Node 来说太粗了。更贴近面试口径的模型应该是:

  • Node 维护了多类待处理任务。
  • 每一轮循环会进入不同 phase。
  • 每个 phase 只处理自己负责的那类回调。
  • phase 之间还夹着更高优先级的 process.nextTick 和微任务清空逻辑。

可以把它理解成一个“分窗口叫号系统”:

  • timers 窗口处理到期定时器。
  • poll 窗口处理 I/O 回调。
  • check 窗口处理 setImmediate
  • close callbacks 窗口处理关闭事件。

不是所有人都在一个总队列里排队,而是 先看当前轮到哪个窗口,再处理对应窗口里的任务


一轮事件循环的标准流程

先记顺序:同步脚本 -> nextTick -> 微任务 -> timers -> pending -> idle/prepare -> poll -> check -> close

上图里最容易被忽略的两点是:

  1. 事件循环不是从 timers 开始理解,而是从“先跑完当前同步代码”开始理解。
  2. 决定事件循环节奏的关键,不是 setTimeout,而是 poll

逐阶段解释:每个 phase 到底干什么

1. 当前同步脚本先执行完

这一步经常被忘掉,但它其实是所有输出题的起点。

比如:

const fs = require('node:fs')

setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
process.nextTick(() => console.log('nextTick'))
Promise.resolve().then(() => console.log('promise'))

fs.readFile(__filename, () => {
console.log('io')
})

console.log('sync')

先发生的永远是:

  • 注册各种异步任务
  • 执行同步 console.log("sync")

也就是说,异步任务不是“立刻执行”,而是先登记,等调度轮到它。

2. process.nextTick 先清空

这不是某个 phase,而是 Node 单独维护的高优先级队列。

它的特点是:

  • 当前同步代码或当前回调一结束,就会先跑它。
  • 如果不断递归塞 nextTick,后面的 phase 就很难获得执行机会。

所以它更像:

  • “先别进下一阶段,先把这批必须马上补完的 JS 回调处理掉。”

3. 微任务再清空

Promise 的 then/catch/finallyqueueMicrotask 属于微任务。

在 Node 里,常见讲法是:

  • 先清 nextTick
  • 再清微任务
  • 然后再让事件循环继续进入下一阶段

这里要提醒版本和模块环境差异:

  • 现代 Node 对微任务和 nextTick 的调度有明确顺序规则。
  • 但在一些输出题里,ESM / CJS、版本差异、运行上下文 都可能影响细节表现。

面试里最稳的答法是:

  • nextTick 优先级通常高于 Promise 微任务。
  • 不要把所有边界输出背成绝对定理。

4. timers

这里处理的是:

  • setTimeout
  • setInterval

但要注意,timer 表示的是“达到最早可执行阈值”,不是“时间一到就立刻入栈执行”。

它能不能立刻跑,还要看:

  • 当前调用栈是否为空
  • 前面有没有更高优先级队列
  • poll 阶段是否停留过久

5. pending callbacks

这一阶段主要处理一部分系统级 I/O 回调,它们不是我们平时最常手写的业务入口,但面试时最好知道:

  • 不是所有 I/O 事件都直接在 poll 里当场执行完。
  • 某些系统错误或延后分发的回调,会在这里处理。

6. idle, prepare

这是 libuv/Node 内部阶段,业务开发几乎不会直接接触。

面试口径:

  • 知道存在即可。
  • 不必把精力浪费在这里,重点还是 timerspollcheck

7. poll

这是最关键的阶段。

它主要做两件事:

  1. 取出已经完成的 I/O 事件并执行相应回调。
  2. 决定当前线程要不要在这里阻塞等待新的 I/O。

这也是为什么 Node 的异步 I/O 高并发能力,归根结底要落到:

  • 主线程不阻塞在无意义的同步等待上
  • 把等待 I/O 的过程交给内核和 libuv

8. check

这里执行 setImmediate

所以 setImmediate 的语义不是“立刻”,而是:

  • 在当前轮 poll 结束后,尽快在 check 阶段执行。

这就是为什么在 I/O 回调里经常能观察到:

  • setImmediatesetTimeout(fn, 0) 更早触发

因为 I/O 回调往往和 poll 紧挨着,后面顺着就进入 check

9. close callbacks

这一步处理资源关闭相关的回调,例如:

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

它通常不是输出题主角,但属于完整事件循环流程的一部分。


为什么很多资料都说 poll 是事件循环的核心

因为它决定了三件大事:

  1. 当前有没有 I/O 结果可处理。
  2. 如果没有,是否要等待新的 I/O。
  3. 如果也不该等,那就应该尽快进入 check 或回到 timers

也就是说,poll 不是一个“普通队列处理点”,而更像一个总调度中心。

这个图就是理解 Node 异步 I/O 的关键。


典型执行场景:I/O、setImmediatesetTimeout(0) 为什么顺序不一样

看这个例子:

const fs = require('node:fs')

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

推荐答法:

  • fs.readFile 完成后,对应回调会在和 I/O 分发有关的位置被调度。
  • 回调内部同时注册了 timer 和 setImmediate
  • 当前轮从 poll 往后走,通常会先进入 check
  • 所以更常观察到 immediate 先于 timeout

但更稳的表达应该是:

  • 根因不是“setImmediate 天生更快”,而是它处在 check,而当前上下文又恰好紧接着 poll

面试高频题:Node 事件循环流程怎么一口气讲清楚

可以直接背这一版:

Node 启动后会先执行当前同步脚本,把定时器、I/O、process.nextTick、Promise 等任务注册出去。当前调用栈清空后,Node 会先处理 nextTick 队列,再处理微任务,然后按 libuv 的 phase 进入 timerspending callbacksidle/preparepollcheckclose callbacks。其中 poll 最关键,因为它既负责处理 I/O 结果,也决定当前线程是否要等待新的 I/O。setImmediatechecksetTimeouttimers,所以它们的先后和当前是不是处于 I/O 场景有关。


常见追问

1. Node 事件循环和浏览器事件循环有什么区别

  • 浏览器重点是 task -> microtask -> render
  • Node 没有渲染阶段,重点是多个 phase 和 libuv 的 I/O 调度。
  • Node 还有 process.nextTick 这条特殊高优先级队列。

2. setTimeout(fn, 0) 为什么不是马上执行

  • 因为它只是达到“最早可被调度”的阈值。
  • 还要等当前调用栈、nextTick、微任务、前序 phase 先完成。

3. setImmediate 为什么经常出现在 I/O 题里

  • 因为它绑定 check 阶段。
  • I/O 场景下,执行完 poll 后就很可能立刻进入 check

4. 为什么说 process.nextTick 用多了会饿死事件循环

  • 因为它不让事件循环继续往后 phase 走。
  • 递归加入 nextTick 时,I/O、timer、setImmediate 都可能一直得不到执行机会。

易错点 / 坑

  • 把 Node 事件循环画成浏览器那种“宏任务、微任务、渲染”三段式。
  • setImmediate 是微任务。
  • setTimeout(fn, 0) 一定先于 setImmediate,或者反过来一刀切。
  • 只会背 phase 名字,但讲不出 poll 为什么最关键。
  • process.nextTick 当成某个 phase 的一部分。

速记要点

  • Node 事件循环重点是 phase,不是单队列。
  • 当前同步代码执行完,先看 nextTick,再看微任务。
  • 常考 phase:timerspollcheck
  • poll 既处理 I/O,又决定要不要等待新的 I/O。
  • setTimeouttimerssetImmediatecheck
  • 现代 Node 里 timer 时机有版本差异,面试应优先讲原理而不是死背输出。