Node.js 事件循环机制工作方法详解(详解 6 阶段与 libuv 实现)

写 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 个阶段,每个阶段处理一类回调队列:

  1. timers:执行到期的 setTimeout / setInterval 回调。底层用最小堆按到期时间排序;这里的延迟是下限,不是精确保证。
  2. pending callbacks:执行上一轮遗留的少量系统回调(如某些 TCP 错误),用户代码通常不直接看到。
  3. idle, prepare:仅供 libuv 内部使用,开发者无法插入。
  4. poll:核心阶段。检索新 I/O 事件、执行已就绪的 I/O 回调;队列空时按情况阻塞等待。
  5. check:执行 setImmediate 回调。它一定在 poll 之后运行。
  6. 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 阶段”是确定性的,事故大多来自主线程被同步任务卡住。

  1. 拆分 CPU 密集任务。crypto.pbkdf2Sync、大数组 JSON.parse、正则灾难性回溯都会霸占主线程。改用异步 API(crypto.pbkdf2)或拆片用 setImmediate 让出 tick,让 I/O 回调能穿插进来。
  2. 监控事件循环延迟。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 机会穿插。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
小码农的头像小码农认证作者

相关推荐

返回顶部