写 Node 程序卡顿 5 秒,setTimeout 设的 100ms 回调却在第 6 秒才执行——这不是 Bug,是事件循环没看懂。Node.js 用单线程跑 JS、用 libuv 在底层异步处理 I/O,事件循环就是把它们穿起来的那根线。下面按”机制拆解 → 阶段流程 → 代码验证”三步,把它讲透。
一、机制拆解:为什么需要事件循环
JavaScript 是单线程语言,若每个 I/O 都同步等待,HTTP 服务会在第一个慢查询上完全卡死。Node 的解法是把”耗时但非 CPU 密集”的操作从主线程挪走,再用一个调度器把完成的事件按顺序送回主线程执行回调。这个调度器,就是事件循环。
整个 Node 运行时由四层组成:
| 层 | 角色 | 负责什么 |
|---|---|---|
| V8 | JS 引擎 | 解析、编译、执行 JS,维护调用栈 |
| libuv | C 语言库 | 事件循环、线程池、跨平台 I/O 抽象 |
| Node 核心 | C++ 绑定 | 把 libuv 能力包装成 fs/net/http 等模块 |
| OS 内核 | 系统调用 | epoll (Linux) / kqueue (macOS) / IOCP (Windows) |
也就是说,JS 主线程只做”调度 + 跑用户代码”,真正干活的 I/O 由 libuv 通过 OS 异步接口或自带的 4 线程线程池完成。回调按阶段排队,主线程一轮一轮清空。
二、阶段流程:事件循环的 6 个阶段
一轮事件循环(一个 tick)按顺序经过 6 个阶段,每个阶段处理一类回调队列:
- timers:执行到期的
setTimeout/setInterval回调。底层用最小堆按到期时间排序;这里的延迟是下限,不是精确保证。 - pending callbacks:执行上一轮遗留的少量系统回调(如某些 TCP 错误),用户代码通常不直接看到。
- idle, prepare:仅供 libuv 内部使用,开发者无法插入。
- poll:核心阶段。检索新 I/O 事件、执行已就绪的 I/O 回调;队列空时按情况阻塞等待。
- check:执行
setImmediate回调。它一定在 poll 之后运行。 - close callbacks:执行
socket.on('close')等关闭事件回调。
┌───────────────────────────┐
┌─>│ timers │ setTimeout / setInterval
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ pending callbacks │ 上一轮遗留
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ idle, prepare │ 内部
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ poll │ I/O 回调 / 阻塞等待
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ check │ setImmediate
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
└──┤ close callbacks │ close 事件
└───────────────────────────┘
每一阶段都有一个 FIFO 回调队列,节点把当前队列里的回调跑完再切下一阶段。两个关键点:
- poll 阶段阻塞策略:队列空时,若已注册
setImmediate则跳到 check 阶段;若 timer 即将到期则绕回 timers 阶段;否则在epoll_wait上阻塞,把 CPU 让给其他进程。 - 微任务穿插:每个回调执行完,Node 都会先清空
process.nextTick队列,再清空 Promise 微任务队列,然后再进入下一阶段。
三、代码验证:用三段例子看真实执行顺序
光看阶段图记不住,写代码跑一遍才扎实。
3.1 主线程被同步任务占用时,timer 会延迟
const start = Date.now();
setTimeout(() => {
console.log('timer fired at', Date.now() - start, 'ms');
}, 100);
const end = Date.now();
while (Date.now() - end < 500) {
// 同步占用主线程 500ms,timer 不会插队
}
运行结果:timer 会在 500ms 之后才触发。结论:timer 的延迟是”最早什么时候能跑”,不是”准时什么时候跑”。
3.2 setTimeout(0) 与 setImmediate 的顺序
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
在主模块顶层调用时,两者顺序不确定:setTimeout(..., 0) 内部会被截断为 1ms,是否到时取决于系统耗时。但放进 I/O 回调里,顺序就稳定了:
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// 永远输出: immediate → timeout
原因:fs 回调在 poll 阶段执行,进入回调时本轮已经走完 timers,下一阶段是 check,所以 setImmediate 必定先跑。
3.3 微任务与 nextTick 的优先级
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
// 输出: nextTick → promise
process.nextTick 队列永远在 Promise 微任务之前清空。滥用 nextTick 会饿死 I/O——一次 nextTick 里再排 nextTick,主线程就转不到 poll 阶段。规则:能用 Promise 解决的,就别用 nextTick。
四、线程池与异步 I/O 的边界
理解事件循环,要清楚”哪些异步走 OS,哪些走线程池”。
| 能力 | 路径 | 说明 |
|---|---|---|
网络 I/O(net、http、dns.resolve) |
OS 异步接口 | epoll / kqueue / IOCP,不占线程池 |
文件 I/O(fs.readFile 等) |
libuv 线程池 | 默认 4 线程,可用 UV_THREADPOOL_SIZE 调大 |
dns.lookup |
线程池 | 底层是同步的 getaddrinfo |
crypto.pbkdf2 / crypto.scrypt |
线程池 | CPU 密集,放主线程会卡住 |
zlib 压缩 |
线程池 | 同上 |
实测一个常见误区:8 个并发 fs.readFile 在 4 线程池上不是”同时 8 个一起跑”,而是 4 个一批、剩余排队。要提升吞吐,调大 UV_THREADPOOL_SIZE 即可,但也会吃更多内存。
五、生产环境的两条避坑建议
事件循环本身的”6 阶段”是确定性的,事故大多来自主线程被同步任务卡住。
- 拆分 CPU 密集任务。
crypto.pbkdf2Sync、大数组JSON.parse、正则灾难性回溯都会霸占主线程。改用异步 API(crypto.pbkdf2)或拆片用setImmediate让出 tick,让 I/O 回调能穿插进来。 - 监控事件循环延迟。
perf_hooks.monitorEventLoopDelay可拿到当前主线程被阻塞的毫秒数;p99 超过 100ms 就该排查同步热路径。
常见问题(FAQ)
Q1:setTimeout(fn, 0) 为什么不能保证立刻执行?
Node 内部把它截断为 1ms,且即便到期也要等主线程当前同步任务跑完,并等到 timers 阶段才会被取出。
Q2:process.nextTick 和 Promise.then 哪个优先级高?
同一次微任务清空里,process.nextTick 队列先于 Promise 微任务队列执行。
Q3:哪些操作会真正卡住事件循环?
CPU 密集的同步代码:例如 crypto.pbkdf2Sync、大体积 JSON.parse、灾难性回溯的正则;这些都跑在主线程上,没有 I/O 机会穿插。