事件循环
约 3107 字大约 10 分钟
2026-04-16
事件循环(Event Loop)是 Node.js 实现非阻塞 I/O 的核心机制。JavaScript 本身是单线程语言,但 Node.js 却能同时处理成千上万的并发连接,秘密就在于事件循环——它让单线程在等待 I/O 时不必空转,而是去处理其他任务。
理解事件循环是理解 Node.js 一切异步行为的前提,从 setTimeout 的执行时机,到 Promise 与 process.nextTick 的优先级,再到为什么不能在请求路径中写同步代码,根源都在这里。
为什么 Node 需要事件循环
JavaScript 在设计之初是浏览器的脚本语言,为了避免多线程操作 DOM 的复杂性,它被设计为单线程。Node.js 沿用了 V8 引擎,因此也继承了单线程模型。
单线程意味着同一时刻只能执行一段代码。如果文件读取、网络请求这类耗时的 I/O 操作采用同步阻塞的方式,整个进程就会卡住,无法响应任何其他请求。
事件循环解决的正是这个矛盾:把耗时的 I/O 操作交给底层(操作系统或线程池)去做,主线程不等待,继续执行后面的代码。当 I/O 完成后,对应的回调被放入队列,事件循环在合适的时机取出并执行。这样单线程也能实现高并发。
Node 的运行架构
Node.js 并不是只有一个 V8。它由几个关键部分协作:
- V8:负责执行 JavaScript 代码,提供调用栈、内存堆
- libuv:一个跨平台的 C 库,提供事件循环、线程池以及对操作系统异步 I/O 能力的封装
- Node API / 内置模块:把 libuv 的能力以 JavaScript 接口的形式暴露出来
┌─────────────────────────────────────┐
│ 你的 JS 代码 │
├─────────────────────────────────────┤
│ Node 内置模块 (fs/net/...) │
├──────────────┬──────────────────────┤
│ V8 │ libuv │
│ (执行 JS) │ (事件循环 + 线程池) │
└──────────────┴──────────────────────┘
操作系统需要澄清一个常见误解:事件循环本身由 libuv 提供,运行在主线程上。所谓 Node 是单线程,指的是执行 JavaScript 的线程只有一个;但 libuv 内部维护了一个线程池,部分操作(如文件 I/O)实际是在这些后台线程中完成的。
调用栈、任务队列与事件循环
要理解执行时机,先看三个概念如何配合:
- 调用栈(Call Stack):V8 执行同步代码的地方,函数调用入栈,返回出栈。栈空了才轮到事件循环介入
- 任务队列(Queue):异步操作完成后,回调被放入对应的队列等待执行
- 事件循环:不断检查调用栈是否为空,为空时按规则从队列中取出回调推入栈执行
一句话概括:同步代码在调用栈上跑完,清空栈后,事件循环才开始一轮一轮地处理异步回调。这就是为什么无论 setTimeout 的延时设为多小,它的回调都一定在所有同步代码之后执行。
libuv 的六个阶段
事件循环的每一轮(称为一个 tick)并不是只有一个队列,而是按固定顺序经过六个阶段,每个阶段都有自己的回调队列:
┌───────────────────────────┐
┌─>│ timers │ ← setTimeout / setInterval 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ ← 上一轮延迟的系统级回调(如 TCP 错误)
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ ← 仅供内部使用
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │ ← 检索新 I/O 事件,
│ └─────────────┬─────────────┘ │ data, etc. │ 执行 I/O 回调
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │ ← setImmediate 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │ ← socket.on('close') 等
└───────────────────────────┘对日常开发真正重要的是其中四个:
timers(定时器阶段):执行已到期的 setTimeout 和 setInterval 回调。注意定时器并不保证精确,它保证的是"到期后尽快执行",实际触发时间取决于事件循环何时进入这个阶段。
poll(轮询阶段):这是最核心的阶段,做两件事——计算应该阻塞多久来等待 I/O,以及处理 poll 队列里的 I/O 回调(如文件读取完成、socket 收到数据)。如果 poll 队列为空,事件循环会在这里停留等待新的 I/O 事件到来,这正是单线程不空转的关键。
check(检查阶段):执行 setImmediate 的回调。setImmediate 的设计目的就是"在 poll 阶段结束后立即执行"。
close callbacks(关闭阶段):执行关闭事件的回调,如 socket.on('close', ...)。
每经过完整一轮六个阶段,算作事件循环的一个 tick。
微任务:nextTick 与 Promise
上面六个阶段处理的都是宏任务。但 Node 还有两个特殊的微任务队列,它们不属于任何阶段,而是在每个阶段之间、以及每个宏任务回调执行完之后立即清空:
- process.nextTick 队列:通过
process.nextTick()注册的回调 - Promise 微任务队列:
Promise.then/catch/finally、queueMicrotask、await之后的代码
两者都会在控制权交回事件循环之前被清空,但 process.nextTick 的优先级高于 Promise 微任务:
Promise.resolve().then(() => console.log('promise'))
process.nextTick(() => console.log('nextTick'))
// 输出:
// nextTick
// promise执行规则可以总结为:每当调用栈清空(一个宏任务执行完),就先把 nextTick 队列全部清空,再把 Promise 微任务队列全部清空,然后才进入下一个宏任务或下一个阶段。
重要
从 Node 11 开始,微任务的清空时机与浏览器对齐:每执行完一个宏任务(比如一个定时器回调)就立即清空微任务,而不是等整个阶段的所有宏任务都执行完。这导致 Node 10 与 Node 11+ 在某些代码上的输出顺序不同。
宏任务与微任务的执行顺序
把同步代码、微任务、宏任务放在一起,执行顺序就清晰了:
console.log('1: 同步开始')
setTimeout(() => console.log('2: setTimeout'), 0)
setImmediate(() => console.log('3: setImmediate'))
process.nextTick(() => console.log('4: nextTick'))
Promise.resolve().then(() => console.log('5: promise'))
console.log('6: 同步结束')
// 输出:
// 1: 同步开始
// 6: 同步结束
// 4: nextTick ← 同步代码跑完,先清微任务,nextTick 优先
// 5: promise ← 再清 Promise 微任务
// 2: setTimeout ← 进入 timers 阶段(与 3 的顺序见下一节)
// 3: setImmediate记忆要点:同步代码 → 微任务(nextTick 先于 Promise)→ 宏任务,每个宏任务之后都会再清一次微任务。
下面是一个体现 Node 11+ 微任务时机的例子:
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(() => console.log('promise1'))
}, 0)
setTimeout(() => {
console.log('timer2')
Promise.resolve().then(() => console.log('promise2'))
}, 0)
// Node 11+ 输出:timer1 → promise1 → timer2 → promise2
// (每个 timer 回调执行完立即清空微任务,再执行下一个 timer)setTimeout 与 setImmediate 的顺序之谜
这是面试高频题。两者在主模块顶层调用时,顺序是不确定的:
setTimeout(() => console.log('setTimeout'), 0)
setImmediate(() => console.log('setImmediate'))
// 输出顺序不确定,取决于进程启动到进入事件循环的耗时原因是 setTimeout(fn, 0) 实际会被设为 1ms。事件循环启动时,如果已经过了 1ms,timers 阶段就先执行 setTimeout;如果还没到 1ms,则先进入 poll,再到 check 执行 setImmediate。这点时间差受机器性能影响,所以顺序随机。
但只要把它们放进一个 I/O 回调里,顺序就完全确定了:
import { readFile } from 'node:fs'
readFile(import.meta.filename, () => {
setTimeout(() => console.log('setTimeout'), 0)
setImmediate(() => console.log('setImmediate'))
})
// 输出恒定:
// setImmediate
// setTimeout因为这段代码运行在 poll 阶段(I/O 回调里),而 check 阶段紧跟在 poll 之后,所以 setImmediate 必然先于下一轮的 timers 阶段执行。
libuv 线程池
并非所有异步操作都依赖操作系统的事件通知。有些操作底层没有异步系统调用,libuv 就用线程池来模拟异步,把它们放到后台线程执行,完成后再把回调交回事件循环。
使用线程池的主要有:
fs模块的文件操作dns.lookup(注意dns.resolve不走线程池)crypto的部分方法(如pbkdf2、scrypt)zlib压缩
线程池默认大小为 4,可以通过环境变量调整:
UV_THREADPOOL_SIZE=8 node app.js这解释了一个现象:同时发起 5 个文件读取,前 4 个会并行,第 5 个要等线程池有空闲线程才开始。如果应用大量使用 crypto 或文件 I/O,适当调大线程池能提升吞吐。
import { pbkdf2 } from 'node:crypto'
const start = Date.now()
for (let i = 0; i < 4; i++) {
pbkdf2('secret', 'salt', 100000, 64, 'sha512', () => {
console.log(`任务 ${i} 完成,耗时 ${Date.now() - start}ms`)
})
}
// 默认线程池为 4,四个任务几乎同时完成
// 若改为 5 个任务,第 5 个会明显晚一截浏览器事件循环与 Node 的差异
两者都有事件循环,但模型不同:
- 宏任务的多队列 vs 单概念:Node 的宏任务分散在 libuv 的多个阶段(timers / poll / check 等),浏览器规范里宏任务概念相对统一
- 独有 API:
process.nextTick和setImmediate是 Node 特有的,浏览器没有;浏览器有requestAnimationFrame - 微任务时机:Node 11 之前与浏览器差异较大,11 之后基本对齐——每个宏任务后清空微任务
理解这点很重要:同一段涉及 setTimeout 与 Promise 的代码,在浏览器和 Node 11+ 中输出一致,但在 Node 10 及更早版本可能不同。
易错点与注意事项
process.nextTick 饿死 I/O:nextTick 队列会在进入下一阶段前被完全清空。如果在 nextTick 回调里递归地继续 nextTick,事件循环将永远无法进入 poll 阶段,I/O 回调被彻底饿死:
// 危险:事件循环再也无法处理 I/O
function loop() {
process.nextTick(loop)
}
loop()长时间同步任务阻塞事件循环:事件循环只有在调用栈清空后才能继续。一段耗时的同步计算(如大循环、同步加密)会卡住整个进程,所有请求都被挂起:
// 危险:这 3 秒内服务器无法响应任何请求
const end = Date.now() + 3000
while (Date.now() < end) {}CPU 密集型任务应该用 worker_threads 或拆分成异步小块处理。
误以为 setTimeout 精确:setTimeout(fn, 1000) 不保证恰好 1000ms 后执行,只保证不早于 1000ms。如果事件循环正忙于其他阶段,回调会被延后。
在请求路径用同步 API:fs.readFileSync 等同步方法会阻塞事件循环,在高并发服务中是致命的,详见 fs 模块 中的说明。
高频问题
问题一:Node 是单线程的吗?
答:执行 JavaScript 的是单线程,但 Node 整体不是单线程。libuv 维护了一个线程池(默认 4 个线程)处理文件 I/O、DNS、crypto 等操作,事件循环本身也运行在主线程上调度这些任务。
问题二:事件循环有哪几个阶段?
答:timers、pending callbacks、idle/prepare、poll、check、close callbacks 六个阶段。常用的是 timers(定时器)、poll(I/O 轮询)、check(setImmediate)三个。
问题三:process.nextTick 和 Promise.then 谁先执行?
答:nextTick 先执行。两者都是微任务,但 Node 维护了独立的 nextTick 队列,优先级高于 Promise 微任务队列。
问题四:setTimeout 和 setImmediate 的执行顺序?
答:在主模块顶层调用时顺序不确定,取决于进程启动耗时;在 I/O 回调内部,setImmediate 一定先于 setTimeout,因为 check 阶段紧跟 poll 阶段。
问题五:为什么不能在 Node 里写 CPU 密集型的同步代码?
答:同步代码运行在主线程的调用栈上,会阻塞事件循环,导致所有异步回调和新请求都无法处理。应使用 worker_threads 或将任务异步分片。
问题六:微任务在什么时候执行?
答:每当一个宏任务执行完、调用栈清空时,会先清空 nextTick 队列,再清空 Promise 微任务队列,然后才进入下一个宏任务或阶段。
总结
事件循环是 Node.js 异步模型的引擎,理解它需要抓住几个关键点:
- 单线程指的是执行 JS 的线程,I/O 由 libuv 的线程池和操作系统异步能力支撑
- 事件循环按 timers → poll → check 等六个阶段循环,每个阶段处理对应的宏任务队列
- 微任务(nextTick 优先于 Promise)在每个宏任务之后、阶段之间被清空
- 同步代码 → 微任务 → 宏任务 是判断执行顺序的总原则
- 绝不能用长时间同步任务或递归 nextTick 阻塞事件循环
掌握事件循环后,再看 setTimeout、Promise、async/await、流的背压等行为,就都能从原理上解释清楚。
