文章

Node.js事件循环深度解析

Node.js 面试天花板:六大阶段执行顺序、nextTick 与 Promise 的优先级差异、setTimeout 和 setImmediate 的时序迷思,一张图讲清单线程如何扛高并发。

Node.js事件循环深度解析

一句话概括

事件循环(Event Loop)是 Node.js「异步非阻塞」的发动机。它跑在主线程上,靠底层的 libuv 把 I/O、定时器、网络请求这些「等待」交给线程池或操作系统去扛,自己只负责按固定顺序把「就绪的回调」一个个捞出来执行——于是单线程也能扛住高并发。

面试里它几乎是必考题,因为它决定了你写的那一堆 setTimeout、Promise、fs.readFile 到底谁先谁后。把阶段顺序和微任务优先级搞清楚,异步代码就不再「看运气」,而是能精准预判输出。

本文结论以当前 LTS(Node 22 / Node 24)为准,这几代的事件循环模型保持一致,可以放心用。

核心知识点

1. 六大阶段:循环每转一圈干了什么

事件循环一轮(tick)按固定顺序走过六个阶段,每个阶段维护自己的回调队列:

1
2
3
4
5
6
7
8
9
   ┌────────── timers(执行到期定时器)───────────┐
   │   ↓        pending callbacks(上一轮推迟的 I/O 回调)│
   │   ↓        idle / prepare(libuv 内部用,无感)    │
   │   ↓        poll(轮询 I/O + 执行 I/O 回调)       │
   │   ↓        check(执行 setImmediate)            │
   │   └──────▶ close callbacks(socket 'close' 等)   │
   │              ↓ 回到 timers 进入下一轮             │
   └────────────────────────────────────────────────┘
   每两个阶段「切换处」:先清空 nextTick 队列 → 再清空 Promise 微任务队列
阶段干什么典型回调
timers执行到期的定时器setTimeout / setInterval
pending callbacks执行上一轮推迟的 I/O 回调个别系统/TCP 错误回调
idle, preparelibuv 内部阶段,开发者无感—
poll轮询新 I/O、执行 I/O 回调;队列空时决定下一步去向fs 读写、网络回调
check紧跟 poll 之后setImmediate
close callbacks执行关闭类的回调socket.on('close')

要点:timers 阶段只看「到期的定时器」,setTimeout(fn, 0) 最少也要等 1ms,且必须等当前同步代码和微任务跑完才可能执行。

2. 微任务:nextTick 比 Promise 还快

Node 里微任务其实有两个队列:process.nextTick 队列,和 Promise 的 microtask 队列。优先级是 nextTick > Promise.then,且它们都在「阶段切换的间隙」被清空,而不是像浏览器那样等整个宏任务结束。

1
2
3
4
5
6
7
8
console.log('1 同步');
process.nextTick(() => console.log('2 nextTick'));
Promise.resolve().then(() => console.log('3 Promise'));
setTimeout(() => console.log('4 setTimeout'), 0);
setImmediate(() => console.log('5 setImmediate'));

// 输出顺序:
// 1 同步 → 2 nextTick → 3 Promise → (4/5 顺序不确定) → (另一个)

解释:同步代码先跑完;之后每次阶段切换,先清空 nextTick 队列(2),再清空 Promise 队列(3);setTimeout 和 setImmediate 属于宏任务,谁先取决于启动耗时(见下节)。

3. setTimeout vs setImmediate:顺序到底听谁的

这是最高频的迷思,答案分两种情况:

1
2
3
4
5
6
7
8
9
10
11
12
const fs = require('fs');

// ❌ 在「顶层」直接比:结果随启动耗时波动,不能当成确定结论
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate')); // 谁先不一定

// ✅ 放在 I/O 回调里比:结果 100% 确定 —— setImmediate 永远先
fs.readFile(__filename, () => {
  setTimeout(() => console.log('setTimeout(下一轮 timers)'));
  setImmediate(() => console.log('setImmediate(本轮 check)'));
});
// 输出:setImmediate → setTimeout

原因:I/O 回调运行在 poll 阶段,之后立刻进入 check 阶段执行 setImmediate;而 setTimeout 要等到下一轮的 timers 阶段。所以「I/O 回调里的 setImmediate 必定先于 setTimeout」是铁律。

4. poll 阶段与「饥饿」:为什么一段大计算会卡死服务

poll 阶段是事件循环最微妙的地方:它发现 I/O 队列空时会「等」,但一旦被一个超长的同步计算占住主线程,定时器、请求全都会被拖后——这就是所谓「饿死」。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ❌ 同步大计算直接卡死事件循环,后面的定时器全被拖后
function heavy() {
  let sum = 0;
  for (let i = 0; i < 5e9; i++) sum += Math.sqrt(i);
  return sum;
}
setTimeout(() => console.log('我本该 0ms 后触发'), 0);
heavy(); // 主线程被占住几百毫秒,定时器迟迟不执行

// ✅ 把计算拆成小块,用 setImmediate 让出事件循环(或丢给 worker_threads)
async function heavyChunked() {
  let sum = 0;
  const step = 1_000_000;
  for (let start = 0; start < 5e9; start += step) {
    const end = Math.min(start + step, 5e9);
    for (let i = start; i < end; i++) sum += Math.sqrt(i);
    await new Promise(resolve => setImmediate(resolve)); // 让出控制权
  }
  return sum;
}

其实你每天都在用

  • fetch / axios 请求:异步结果通过 Promise 微任务在定时器之后回来,正是事件循环在调度
  • setTimeout(fn, 0) 做「延后到下一轮」:你以为立刻执行,其实至少等过当前同步代码 + 所有微任务
  • fs.readFile:读文件的回调排在 poll 阶段,I/O 完成时事件循环把它捞出来执行
  • process.nextTick:很多库用它做「当前操作结束后、下一轮前」的统一收尾(如错误优先回调里先发事件再返回)
  • setImmediate 拆批:处理大数组/大循环时用来让出事件循环,避免阻塞请求
  • 用 Promise 写的接口:.then 注册的是微任务,永远在本轮「阶段切换处」先被清空

常见误解(FAQ)

❌ 误区一:「事件循环是另开一个线程在后台跑」

事件循环本身运行在主线程上,是单线程在挨个执行回调。真正在后台干活的是 libuv 的线程池(默认 4 个,处理文件 I/O、DNS、crypto 等)和操作系统内核(网络 I/O 用 epoll/kqueue)。所以「单线程」指的是 JS 执行为单线程,不是「只有一个线程在忙」。

❌ 误区二:「setTimeout(fn, 0) 就等于立即执行」

它是「最少 0ms」——实际有最小延迟(历史上 1ms 起步),而且必须等当前同步代码和所有微任务跑完、且要等到下一轮 timers 阶段才可能被触发。耗时计算卡住主线程时,它会严重延迟。要真正不阻塞,应该用 Promise 化的 sleep 而非忙等。

❌ 误区三:「Promise.then 比 process.nextTick 先执行」

正好反过来。process.nextTick 的队列优先级高于 Promise 微任务队列,每次阶段切换都先清空 nextTick 再清空 Promise。也正因为如此,递归调用 nextTick 会一直占住主线程、饿死 I/O 回调,这种场景应改用 setImmediate 递归。

❌ 误区四:「Node 的事件循环和浏览器一模一样」

大框架一致(宏任务 + 微任务),但 Node 多出了 process.nextTick 和 setImmediate,且微任务是在「每个阶段切换处」清空,而不是浏览器那样「每个宏任务结束后」。面试被问差异时,这三点是关键区分。

一句话总结

事件循环不是黑魔法,它就是「主线程 + libuv 底层」配合出的一个按阶段排队的调度器——记住六大阶段的顺序、nextTick > Promise 的微任务优先级、以及「I/O 回调里 setImmediate 必先于 setTimeout」,你写的异步代码就从「撞大运」变成「可预判」。

本文由作者按照 CC BY 4.0 进行授权