Node 事件循环流程怎么讲:从启动脚本到一轮轮处理异步任务
面试速答(30 秒版 TL;DR)
- Node 的事件循环本质上是 libuv 驱动的一轮轮调度循环,不是“只有一个回调队列”那么简单。
- 一轮循环里,Node 会按阶段处理不同来源的任务,常见口径是:
timers→pending callbacks→idle, prepare→poll→check→close callbacks。 - 真正决定“这一轮要不要继续跑、在哪儿阻塞等待”的核心阶段是
poll,因为 I/O 结果大多会在这里被取出并分发。 process.nextTick不属于事件循环 phase;它会在当前 JS 操作结束后优先清空。Promise 微任务也会在合适时机清空,但通常晚于nextTick。- 从 Node 20 开始,官方文档特别提示:timer 的执行时机和更早版本有差异,讲原理时要优先抓“阶段职责”和“调度关系”,不要死背所有输出顺序。
先把问题讲对:Node 为什么能做异步 I/O
Node 执行 JavaScript 时,主线程一次只能跑一段同步代码,但异步 I/O 并不是主线程自己“后台偷偷做完”的。
更准确的说法是:
- JS 线程负责执行业务代码和回调。
- libuv 负责维护事件循环、定时器、I/O 观察器、线程池任务完成通知等基础设施。
- 操作系统内核负责网络 socket、文件描述符等底层事件通知。
- 当 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。
上图里最容易被忽略的两点是:
- 事件循环不是从
timers开始理解,而是从“先跑完当前同步代码”开始理解。 - 决定事件循环节奏的关键,不是
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/finally 和 queueMicrotask 属于微任务。
在 Node 里,常见讲法是:
- 先清
nextTick - 再清微任务
- 然后再让事件循环继续进入下一阶段
这里要提醒版本和模块环境差异:
- 现代 Node 对微任务和
nextTick的调度有明确顺序规则。 - 但在一些输出题里,ESM / CJS、版本差异、运行上下文 都可能影响细节表现。
面试里最稳的答法是:
nextTick优先级通常高于 Promise 微任务。- 不要把所有边界输出背成绝对定理。
4. timers
这里处理的是:
setTimeoutsetInterval
但要注意,timer 表示的是“达到最早可执行阈值”,不是“时间一到就立刻入栈执行”。
它能不能立刻跑,还要看:
- 当前调用栈是否为空
- 前面有没有更高优先级队列
poll阶段是否停留过久
5. pending callbacks
这一阶段主要处理一部分系统级 I/O 回调,它们不是我们平时最常手写的业务入口,但面试时最好知道:
- 不是所有 I/O 事件都直接在
poll里当场执行完。 - 某些系统错误或延后分发的回调,会在这里处理。
6. idle, prepare
这是 libuv/Node 内部阶段,业务开发几乎不会直接接触。
面试口径:
- 知道存在即可。
- 不必把精力浪费在这里,重点还是
timers、poll、check。
7. poll
这是最关键的阶段。
它主要做两件事:
- 取出已经完成的 I/O 事件并执行相应回调。
- 决定当前线程要不要在这里阻塞等待新的 I/O。
这也是为什么 Node 的异步 I/O 高并发能力,归根结底要落到:
- 主线程不阻塞在无意义的同步等待上
- 把等待 I/O 的过程交给内核和 libuv
8. check
这里执行 setImmediate。
所以 setImmediate 的语义不是“立刻”,而是:
- 在当前轮
poll结束后,尽快在check阶段执行。
这就是为什么在 I/O 回调里经常能观察到:
setImmediate比setTimeout(fn, 0)更早触发
因为 I/O 回调往往和 poll 紧挨着,后面顺着就进入 check。
9. close callbacks
这一步处理资源关闭相关的回调,例如:
socket.on("close", ...)
它通常不是输出题主角,但属于完整事件循环流程的一部分。
为什么很多资料都说 poll 是事件循环的核心
因为它决定了三件大事:
- 当前有没有 I/O 结果可处理。
- 如果没有,是否要等待新的 I/O。
- 如果也不该等,那就应该尽快进入
check或回到timers。
也就是说,poll 不是一个“普通队列处理点”,而更像一个总调度中心。
这个图就是理解 Node 异步 I/O 的关键。
典型执行场景:I/O、setImmediate、setTimeout(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 进入timers、pending callbacks、idle/prepare、poll、check、close callbacks。其中poll最关键,因为它既负责处理 I/O 结果,也决定当前线程是否要等待新的 I/O。setImmediate在check,setTimeout在timers,所以它们的先后和当前是不是处于 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:
timers、poll、check。 poll既处理 I/O,又决定要不要等待新的 I/O。setTimeout看timers,setImmediate看check。- 现代 Node 里 timer 时机有版本差异,面试应优先讲原理而不是死背输出。