深入理解 Node.js 事件循环机制(完整解析与实战指南)
先看一段代码,你能不假思索说出它的输出顺序吗:

console.log('1')
setTimeout(() => console.log('2'), 0)
Promise.resolve().then(() => console.log('3'))
process.nextTick(() => console.log('4'))
console.log('5')
正确答案是 1 5 4 3 2。如果你第一反应是 1 5 2 3 4,那这篇文章就是为你准备的——setTimeout 设置了 0 毫秒延迟却最后才执行,nextTick 写在后面却优先被调度,这背后的根本原因正是 Node.js 事件循环机制在按规则排队。
带新人时发现,大多数开发者对事件循环的理解仅停留在「Node 是单线程的,依靠事件循环处理异步操作」这一句话。这句话本身没错,但它无法解释上面的输出顺序,也无法解释线上环境中一个同步的 JSON.parse 大对象为何能将整个服务卡死。现在,让我们把这台「调度机器」彻底拆开看一遍。
事件循环不是 V8 提供的,而是 libuv 提供的
很多人下意识以为事件循环是 JavaScript 引擎的一部分,其实不然。V8 只负责执行 JS 代码、管理堆内存和调用栈,它完全不关心「定时器」「网络 IO」等概念。
真正承担调度工作的底层库是 libuv——一个用 C 语言编写的跨平台异步 IO 库,Node 将其作为底层基础设施。你写的 fs.readFile、setTimeout、监听端口等操作,最终都会交给 libuv 处理。事件循环本质上就是 libuv 中的一个主循环,它不断询问:「现在有哪些回调已经达到执行条件?该执行哪一个?」
因此,「Node 是单线程的」这句话需要补充完整:执行你的 JS 代码的线程是单线程,但底层的 IO 操作由 libuv 利用线程池和操作系统的异步能力来完成。这个区分非常关键,后面讲解线程池时会再次提及。
六个阶段,循环往复
libuv 的事件循环每一轮(官方称为 tick)会依次经过六个阶段,每个阶段维护着自己的回调队列。Node 官方文档《The Node.js Event Loop》给出的顺序如下:
┌───────────────────────────┐
┌─>│ timers │
│ │ setTimeout / setInterval │
│ │ 的回调 │
│ └─────────────┬─────────────┘
│ │
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ │ 少数系统级回调,如某些 │
│ │ TCP 错误 │
│ └─────────────┬─────────────┘
│ │
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ │ Node 内部用,业务无感 │
│ └─────────────┬─────────────┘
│ │
│ ┌─────────────┴─────────────┐
│ │ poll │
│ │ ← 真正的重头戏:取 IO 事件 │
│ │ 、执行 IO 回调 │
│ └─────────────┬─────────────┘
│ │
│ ┌─────────────┴─────────────┐
│ │ check │
│ │ setImmediate 的回调 │
│ └─────────────┬─────────────┘
│ │
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
│ socket.on('close') 之类 │
└───────────────────────────┘
对日常业务开发来说,真正需要牢记的是三个阶段:timers、poll 和 check。其余三个阶段要么是 Node 内部使用,要么只在极边缘的场景(比如 TCP 连接被拒绝)才会遇到。
- timers:检查是否有
setTimeout/setInterval的回调到期。注意「到期」并不等于「精确执行」——如果你写setTimeout(fn, 100),Node 只保证至少 100 毫秒后才会尝试执行,实际执行时间还要看事件循环何时转到这个阶段,以及前面是否有其他任务占用了线程。 - poll:整个事件循环的核心阶段。它主要做两件事:计算应该阻塞等待多久,然后执行 poll 队列中的 IO 回调(例如文件读取完成、socket 数据到达)。如果队列为空,它会在此处停下来等待新的 IO 事件,这就是空闲的 Node 进程不占 CPU 的原因。
- check:专门执行
setImmediate的回调。设计这个阶段的目的是提供一个「在 poll 之后、下一轮 timers 之前」插队执行的机会。
真正的插队者:nextTick 与微任务
以上六个阶段属于「宏观」队列。但还存在两条优先级更高的队列,它们不属于任何一个阶段,而是在每个阶段切换的间隙被完整清空:
process.nextTick队列- 微任务队列(包括 Promise 的
.then/.catch/.finally、queueMicrotask、await之后的代码)
执行规则是:当前这一步的同步代码执行完毕后,在进入下一个阶段之前,先清空 nextTick 队列,再清空微任务队列。只有当两者都为空时,事件循环才会继续向下推进。而且 nextTick 的优先级高于 Promise 微任务。
现在回头重新审视开头那段代码,一切就清晰了:
console.log('1') // 同步,立即输出
setTimeout(() => console.log('2'), 0) // 进入 timers 阶段队列
Promise.resolve().then(() => console.log('3')) // 进入微任务队列
process.nextTick(() => console.log('4')) // 进入 nextTick 队列
console.log('5') // 同步,立即输出
主模块的同步代码先执行完毕 → 输出 1、5。同步代码结束后,先清空 nextTick 队列 → 输出 4,再清空微任务队列 → 输出 3。这两个队列清空后,事件循环才正式开始第一轮,轮到 timers 阶段执行 setTimeout → 输出 2。因此最终结果是 1 5 4 3 2。
这里有一个容易踩的坑需要提醒:process.nextTick 从名字上看像是「下一轮 tick 再执行」,但实际情况恰恰相反——它会在当前操作结束后立即执行,比任何 IO 回调都早。如果你写了一个会递归调用 process.nextTick 的逻辑,那么事件循环将永远无法进入下一个阶段,IO 回调一个都跑不了,表现在线上就是服务假死但不报错。我们曾经在生产环境遇到过类似问题,排查了大半天才定位到一个「看起来人畜无害」的递归 nextTick 调用。
那道经典面试题:setTimeout(fn,0) 和 setImmediate 谁先?
这是 Node.js 面试中的常客,而且答案是「视情况而定」,而这恰恰是它最有意思的地方。
情况一:写在主模块里
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
这段代码的输出顺序不确定,可能是 timeout 先输出,也可能是 immediate 先输出。原因是 setTimeout(fn, 0) 实际会被钳制到最小 1 毫秒,而进程启动后、运行到 timers 阶段所花费的时间是不确定的——如果准备耗时不足 1 毫秒,timer 还没到期,这一轮的 timers 阶段就会空转过去,先轮到 check 阶段执行 setImmediate;反之则是 timeout 先执行。这个顺序与机器当时的负载有关,所以千万不要在生产代码里依赖它们俩的相对顺序。
情况二:写在一个 IO 回调里
const fs = require('node:fs')
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
})
这段代码会稳定输出 immediate 先、timeout 后,百分之百如此。因为这段代码本身就在 poll 阶段(IO 回调在这里执行)中运行,poll 之后紧接着就是 check 阶段,setImmediate 在本轮就被执行;而 setTimeout 需要等待下一轮循环绕回到 timers 阶段。所以,「如果你希望在当前 IO 处理完毕后,尽快再安排一个回调」,正确的选择是 setImmediate 而不是 setTimeout(fn, 0),后者还要多等待一整圈事件循环。
别把事件循环和线程池搞混
说到这里,必须澄清一个高频误解:不是所有异步操作都靠事件循环,有一部分实际操作依赖 libuv 的线程池。
libuv 维护一个默认包含 4 个线程的线程池(可通过环境变量 UV_THREADPOOL_SIZE 调整,上限为 1024)。根据 libuv 官方文档,它处理那些操作系统没有提供好的异步接口、或者属于纯 CPU 密集型的工作,主要包括:
- 文件系统操作(
fs.*) - DNS 解析中的
dns.lookup(底层调用getaddrinfo) crypto模块的部分操作(如pbkdf2、scrypt)zlib压缩
而网络 IO(TCP、UDP、HTTP)走的是另一条路径——操作系统原生的事件通知机制(epoll / kqueue / IOCP),不占用线程池。
这个区分有实际的性能后果。线程池只有 4 个线程,意味着如果你同时发起 5 个耗时的文件读取或 pbkdf2 调用,第 5 个就必须排队等待前面某个线程释放。我曾经见过一个服务在登录高峰期变慢,profiler 分析后发现瓶颈竟然是密码哈希——pbkdf2 把 4 个线程全部占满,后续请求全部在排队等待。解决方案很简单:启动前将 UV_THREADPOOL_SIZE 调大。但必须注意,这个值必须在 Node 进程启动之前设置好,进程运行后再修改无效。
落到实处:不要阻塞事件循环
理解事件循环,最大的实战价值归结为一句话:执行你的 JS 代码的是单线程,一旦你用一段长时间的同步代码占据它,整个服务的所有请求都得跟着卡死。
典型的「凶手」包括:
// 同步读取大文件 —— 在这行代码读完之前,服务无法处理任何其他请求
const data = fs.readFileSync('./huge.json')
// 一个 O(n²) 的双重循环处理大数组
for (let i = 0; i < arr.length; i++)
for (let j = 0; j < arr.length; j++) { /* ... */ }
// JSON.parse / JSON.stringify 一个几十 MB 的对象,也是同步阻塞
const obj = JSON.parse(hugeString)
这些操作不仅仅是「慢」,而是在它们运行期间,事件循环根本无法转动,新进来的 HTTP 请求甚至没有被 accept 的机会。判断一段代码是否会阻塞事件循环的标准很简单:它是否是同步的,并且它的执行时间是否随输入规模增长。
应对思路:优先使用异步版本(例如用 fs.promises.readFile 替代 readFileSync);如果确实有 CPU 密集计算,将其迁移到 worker_threads 或者拆分成小块分批处理,绝对不要让任何一段同步逻辑长时间霸占主线程。这部分内容 Node 官方专门有一篇《Don't Block the Event Loop》值得深入阅读。
小结一张图
把大脑中的模型理顺,大概是这样:
- JS 执行是单线程,事件循环由 libuv 驱动,而不是 V8
- 一轮循环依次经过 timers → pending → idle/prepare → poll → check → close,业务开发重点关注 timers / poll / check
- 每个阶段之间会清空 nextTick 队列和微任务队列,nextTick 比 Promise 微任务更早执行
- 在 IO 回调中想尽快再安排一次回调,使用
setImmediate;在主模块中setTimeout(0)与setImmediate的顺序不保证 - 文件 / DNS / crypto / zlib 走 4 线程的线程池,网络 IO 走操作系统事件机制
- 永远不要用长同步代码阻塞主线程
把这套模型装入脑海,你再遇到任何「异步顺序为什么是这样」的问题,基本都能自己推导出来,而不需要每次都跑一遍代码试错。
参考来源
- The Node.js Event Loop(Node.js 官方)(六阶段、nextTick 与 setImmediate,采集于 2026-06-29)
- Don't Block the Event Loop(Node.js 官方)(阻塞主线程,采集于 2026-06-29)
- libuv — Thread pool work scheduling(线程池默认 4、上限 1024、覆盖 fs/dns/crypto/zlib,采集于 2026-06-29)
- Node.js — Releases(Node 24 为当前 Active LTS,采集于 2026-06-29)
