Node.js 事件循环到底和浏览器差在哪?5 个实验了解 libuv 真实调度

Node.js 事件循环到底和浏览器差在哪?5 个实验了解 libuv 真实调度

Posted by LBX on August 13, 2026

1. 浏览器和Node.js:为什么需要两套?

浏览器和 Node.js 的事件循环不是同一个东西

浏览器的事件循环由 HTML5 规范定义,核心是任务队列(Task Queue)和微任务队列(Microtask Queue)。注意「宏任务」只是约定俗成的叫法,并非规范术语——浏览器规范里用的是 Task。而且 Task Queue 实际上有多个,分别对应不同的任务源(setTimeout 回调、DOM 事件、网络请求回调等),浏览器按优先级从不同任务源中取出任务执行。因为浏览器主要干两件事:跑 JS 和渲染页面。

Node.js 不一样。它要处理网络请求、文件 I/O、定时器、DNS 查询等等,这些操作不可能都塞在两个队列里。所以 Node.js 用了 libuv 这个 C 库来实现事件循环,把回调分成了 6 个阶段,每个阶段有自己的队列。

简单来说:

  浏览器 Node.js
实现方 浏览器引擎(V8/Blink) libuv(C 库)
队列数 任务队列(Task Queue)+ 微任务队列(Microtask Queue) 6 个阶段
渲染 有 requestAnimationFrame 无渲染阶段
线程池 有底层线程池(网络、文件、IndexedDB 等),但 JS 调度层为单线程 有(默认 4 线程)
独有 API requestAnimationFrame process.nextTick、setImmediate

所以同一个概念,两套实现


2. Node.js 事件循环的 6 个阶段

Node.js 事件循环每一轮按顺序经过 6 个阶段:

在这里插入图片描述

逐一说明:

  1. timers:执行 setTimeoutsetInterval 到期的回调
  2. pending callbacks:执行上一轮循环延迟的 I/O 回调(少见)
  3. idle/prepare:libuv 内部使用,JS 层接触不到
  4. poll:核心阶段,执行 I/O 回调(文件读写、网络数据到达等)
  5. check:执行 setImmediate 注册的回调
  6. close callbacks:执行 close 事件回调(如 socket.on('close')

关键规则:每个阶段之间,会清空微任务队列。微任务包括 process.nextTickPromise.then,其中 nextTick 的优先级比 Promise 还高。

光看概念记不住。下面用 5 个实验验证每个规则。


3. 实验1:setTimeout vs setImmediate — 顺序不确定

先从最经典的面试题开始。

1
2
3
4
5
6
7
setTimeout(() => {
  console.log('setTimeout');
}, 0);

setImmediate(() => {
  console.log('setImmediate');
});

在浏览器里,setTimeout(0) 一定先于 setImmediate… 等等,浏览器里没有 setImmediate。这是 Node.js 独有的 API。

那在 Node.js 里呢?跑 10 次看看:

1
2
3
4
$ for i in $(seq 1 10); do node -e "
  setTimeout(() => console.log('setTimeout'), 0);
  setImmediate(() => console.log('setImmediate'));
"; done

输出:

1
2
3
4
5
setImmediate   # 第1次
setTimeout
setImmediate   # 第2次
setTimeout
...(10 次全是 setImmediate 在前)

看起来 setImmediate 总是先?别急,换个写法试试:

1
2
3
4
5
6
// 用纯 CPU 阻塞模拟同步代码耗时超过 1ms
const start = Date.now();
while (Date.now() - start < 5) {} // 阻塞 5ms

setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));

这次跑出来大概率是 setTimeout 先。

原因: setTimeout(0) 在 Node.js 里会被钳制为 1ms。如果事件循环进入 timers 阶段时还没到 1ms,定时器回调不执行,直接跳到 check 阶段,setImmediate 就先了。加了上面的阻塞循环,同步代码执行时间超过 1ms,定时器已经到期,setTimeout 就先了。

这个结论不是绝对的。在多核高配机器上跑原始版本,setImmediate 先是极大概率事件。但如果机器 CPU 负载飙升,Node 启动时主线程被调度中断超过 1ms,setTimeout 依然可能先打印。

在主模块(非 I/O 回调)里,setTimeout(0)setImmediate 的顺序是不确定的,取决于系统性能。


4. 实验2:I/O 回调里,setImmediate 一定先

但在 I/O 回调里,顺序是确定的

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

fs.readFile(__filename, () => {
  console.log('I/O 回调执行(poll 阶段)');

  setTimeout(() => {
    console.log('  setTimeout(timers 阶段)');
  }, 0);

  setImmediate(() => {
    console.log('  setImmediate(check 阶段)');
  });
});

跑 5 次:

1
2
3
I/O 回调执行(poll 阶段)
  setImmediate(check 阶段)    ← 永远先
  setTimeout(timers 阶段)    ← 永远后

5 次结果完全一样。为什么?

因为 I/O 回调在 poll 阶段执行。poll 阶段之后,事件循环直接进入 check 阶段(执行 setImmediate),然后才进入下一轮的 timers 阶段(执行 setTimeout)。

这是 Node.js 事件循环阶段顺序的直接体现——不是随机,是 libuv 的设计。

结论:在 I/O 回调里注册 setTimeoutsetImmediatesetImmediate 一定先执行。


5. 实验3:nextTick vs Promise vs 宏任务

完整的优先级链验证:

1
2
3
4
5
6
7
8
9
10
setTimeout(() => console.log('4. setTimeout(宏任务)'), 0);
setImmediate(() => console.log('5. setImmediate(宏任务)'));

Promise.resolve().then(() => console.log('2. Promise.then(微任务)'));

process.nextTick(() => console.log('1. process.nextTick(最高优先级)'));

Promise.resolve().then(() => console.log('3. Promise.then(第二个微任务)'));

console.log('0. 同步代码');

输出:

1
2
3
4
5
6
0. 同步代码
1. process.nextTick(最高优先级)
2. Promise.then(微任务)
3. Promise.then(第二个微任务)
4. setTimeout(宏任务)
5. setImmediate(宏任务)

验证了优先级链:同步代码 → nextTick → Promise → setTimeout/setImmediate

process.nextTick 是 Node.js 独有的微任务,优先级比 Promise 还高。浏览器里没有这个 API,只有 queueMicrotaskPromise.then

完整顺序验证

把所有类型的任务混在一起跑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 顶层注册
setTimeout(() => console.log('1. [timers] setTimeout(0)'));
setImmediate(() => console.log('2. [check] setImmediate'));
process.nextTick(() => console.log('3. [nextTick] 顶层'));
Promise.resolve().then(() => console.log('4. [promise] 顶层'));

// I/O 操作
const fs = require('fs');
fs.readFile(__filename, () => {
  console.log('5. [poll] fs.readFile I/O 回调');

  process.nextTick(() => console.log('6. [nextTick] I/O 回调里注册'));
  Promise.resolve().then(() => console.log('7. [promise] I/O 回调里注册'));
  setTimeout(() => console.log('8. [timers] I/O 回调里注册 setTimeout'));
  setImmediate(() => console.log('9. [check] I/O 回调里注册 setImmediate'));
});

console.log('0. [sync] 同步代码结束');

输出(数字为注册顺序,不代表执行顺序):

1
2
3
4
5
6
7
8
9
10
0. [sync] 同步代码结束
3. [nextTick] 顶层
4. [promise] 顶层
1. [timers] setTimeout(0)
2. [check] setImmediate
5. [poll] fs.readFile I/O 回调
6. [nextTick] I/O 回调里注册
7. [promise] I/O 回调里注册
9. [check] I/O 回调里注册 setImmediate
8. [timers] I/O 回调里注册 setTimeout

注意 I/O 回调之后的顺序:poll 回调 → nextTick → Promise → setImmediate → setTimeout。完美验证了阶段间的微任务清空机制和阶段顺序。


6. libuv 线程池:Node.js 不是单线程

说到 Node.js,十个人有九个说”单线程”。这个说法不算错——JS 代码确实只在主线程跑。但 libuv 内部有一个线程池,默认 4 个线程,专门处理那些操作系统不能异步完成的任务。

哪些操作走线程池?

  • 文件 I/Ofs.readFilefs.writeFile(大部分操作系统没有异步文件 I/O)
  • cryptocrypto.scryptcrypto.pbkdf2(CPU 密集型)
  • zlibzlib.gzipzlib.deflate
  • dns.lookup:DNS 查询(dns.resolve 不走线程池,用的是 c-ares 库)

哪些不走线程池?

  • 网络 I/Ohttp.requestnet.connect(用 epoll/kqueue 多路复用)
  • 定时器setTimeoutsetInterval(主线程计时)

这就是为什么 Node.js 能扛上万并发网络连接(epoll 多路复用),但并发读大文件会卡(线程池只有 4 个)。

在这里插入图片描述

实验4:UV_THREADPOOL_SIZE 瓶颈验证

crypto.scrypt 做实验——20 个 CPU 密集型任务,分别用 1/4/8 个线程跑(实验机器为 2 核 CPU):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 线程池设为 1
$ UV_THREADPOOL_SIZE=1 node 05c-threadpool-bottleneck.js
  20 个任务,总耗时 680ms
  每个任务等待→完成时间: [40, 84, 117, 150, 186, 219, 251, 284, ...]ms

# 默认 4 线程
$ UV_THREADPOOL_SIZE=4 node 05c-threadpool-bottleneck.js
  20 个任务,总耗时 630ms
  每个任务等待→完成时间: [67, 143, 159, 171, 205, 283, 294, 294, ...]ms

# 线程池设为 8
$ UV_THREADPOOL_SIZE=8 node 05c-threadpool-bottleneck.js
  20 个任务,总耗时 754ms  ← 反而更慢!
  每个任务等待→完成时间: [322, 343, 370, 370, 382, 383, 383, 383, ...]ms

看数据说话:

  • 1 线程:任务串行排队,每个间隔约 33ms,总耗时 680ms
  • 4 线程:前 4 个并行完成(67/143/159/171ms),然后下一批,总耗时 630ms
  • 8 线程:前 8 个同时开始,但因为只有 2 个 CPU 核,线程争抢导致每个任务更慢,总耗时 754ms。crypto.scrypt 是 CPU 密集型,过多的线程会导致 CPU 时间片争抢

关键教训:线程池不是越大越好。 线程数超过 CPU 核数,上下文切换的开销反而拖慢速度。一般来说,UV_THREADPOOL_SIZE 设为 CPU 核数的 1-2 倍就够了。


7. 实验5:nextTick 饿死 I/O

process.nextTick 优先级太高,用不好就会出问题。注册 1000 个 nextTick,看看 I/O 回调要等多久:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const fs = require('fs');

const ioStart = Date.now();
fs.readFile(__filename, () => {
  console.log(`fs.readFile 回调执行,等了 ${Date.now() - ioStart}ms`);
});

// 注册 1000 个 nextTick
for (let i = 0; i < 1000; i++) {
  process.nextTick(() => {});
}

// 对照:1000 个 Promise
for (let i = 0; i < 1000; i++) {
  Promise.resolve().then(() => {});
}

setTimeout(() => console.log('setTimeout 执行'), 0);

console.log('同步代码结束');

输出:

1
2
3
4
5
同步代码结束
(1000 个 nextTick 全部执行)
(1000 个 Promise 全部执行)
setTimeout 执行
fs.readFile 回调执行,等了 8ms

I/O 回调等了 8ms 才执行——因为 2000 个微任务(1000 nextTick + 1000 Promise)全部跑完了才轮到宏任务。

如果 nextTick 数量再大一点,或者每个 nextTick 里又注册新的 nextTick(递归),I/O 回调可能永远得不到执行。这就叫 I/O starvation

结论:不要在热路径上大量使用 process.nextTick。如果只是想在当前操作完成后立即执行回调,setImmediate 更安全——它不会饿死 I/O。


8. 浏览器 vs Node.js:5 个真实区别

根据实验总结出来几个区别点:

区别点 浏览器 Node.js
阶段数 任务队列 + 微任务队列 6 个阶段(timers→pending→idle→poll→check→close)
微任务执行时机 每个宏任务后 每个阶段之间(Node 11+ 后行为趋同,但底层实现仍有微小差异)
独有微任务 queueMicrotask process.nextTick(优先级高于 Promise)
独有宏任务 requestAnimationFrame setImmediate(check 阶段)
线程池 有底层线程池(Web Worker 需手动创建) libuv 内置(默认 4 线程,可调)

第 2 点展开说一下。Node.js 11 之前,微任务在每个阶段之间执行——一轮循环跑完 6 个阶段,才清一次微任务。Node.js 11 之后,改为每个宏任务后执行微任务,行为上和浏览器趋同。在 99.9% 的业务代码中看不出差别,但严格来说,底层实现仍有微小差异:浏览器在执行完一个宏任务后立即清空微任务队列;Node.js(libuv)是在阶段切换之间清空微任务,以及在 poll 阶段每次执行完一个回调后也会清空。在边缘场景(如在 setImmediate 回调中嵌套大量递归微任务)时,行为可能有细微差别——浏览器在当前宏任务结束后立刻清空微任务,Node.js 则可能先处理完当前阶段内剩余回调,再在阶段切换时清空。但这种差异几乎不可能影响业务代码,了解即可。所以你在网上看到的旧文章说”Node 微任务执行时机和浏览器不同”,现在不准确了——但说”完全一致”也不够严谨。


9. 面试题收尾

5 道题,每道都和上面的实验对应。自己先想答案,再往下看。

题目1

1
2
setTimeout(() => console.log('A'), 0);
setImmediate(() => console.log('B'));

A 和 B 谁先打印?

不确定。 在主模块里,setTimeout(0)setImmediate 的顺序取决于 1ms 是否已到。如果同步代码执行快,setImmediate 先;如果慢,setTimeout 先。

但在 I/O 回调里,setImmediate 一定先(实验2 验证)。

题目2

1
2
Promise.resolve().then(() => console.log('C'));
process.nextTick(() => console.log('D'));

C 和 D 谁先?

D 先。 process.nextTick 的优先级高于 Promise.then,两者都是微任务,但 nextTick 有独立的队列,会先清空。

这是 Node.js 独有行为,浏览器里没有 process.nextTick

题目3

1
2
3
4
fs.readFile('file.txt', () => {
  setTimeout(() => console.log('E'), 0);
  setImmediate(() => console.log('F'));
});

E 和 F 谁先?

F 先。 I/O 回调在 poll 阶段执行,之后直接进入 check 阶段(setImmediate),然后才是下一轮的 timers 阶段(setTimeout)。5 次验证结果一致(实验2)。

题目4

以下代码会输出什么?会不会有 I/O starvation 风险?

1
2
3
4
5
6
function tick() {
  process.nextTick(tick);
}
tick();

setTimeout(() => console.log('timeout'), 1000);

timeout 永远不会打印。 process.nextTick 递归调用会无限清空 nextTick 队列,事件循环永远进不了下一个阶段。这就是经典的 I/O starvation。

修复方法:把 process.nextTick 换成 setImmediate,后者在 check 阶段执行,不会阻塞其他阶段。

1
2
3
function tick() {
  setImmediate(tick); // 安全
}

题目5

你的 Node.js 服务并发处理 100 个文件读取请求,但响应很慢。可能的原因是什么?怎么优化?

libuv 线程池瓶颈。 文件 I/O 走线程池,默认只有 4 个线程。100 个请求排队,前 4 个处理完才开始下一批。

优化方案:

  1. 调大 UV_THREADPOOL_SIZE(不超过 CPU 核数的 2 倍)
  2. 用 Stream 流式读取代替 fs.readFile(减少线程池占用时间。关于 Stream 的具体原理,见下一篇)
  3. CPU 密集型任务用 worker_threads 而不是占主线程

但注意:实验4 验证过,线程池设太大(8 线程 vs 2 核 CPU)反而更慢——上下文切换开销。


10. 知识图谱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Node.js 事件循环
├── 6 个阶段(libuv 实现)
│   ├── timers     → setTimeout / setInterval
│   ├── pending    → 延迟的 I/O 回调
│   ├── idle       → libuv 内部
│   ├── poll       → I/O 回调(核心阶段)
│   ├── check      → setImmediate
│   └── close      → close 事件回调
│
├── 微任务(阶段之间执行)
│   ├── process.nextTick(最高优先级,Node 独有)
│   └── Promise.then(标准微任务)
│
├── libuv 线程池
│   ├── 默认 4 线程(UV_THREADPOOL_SIZE 可调)
│   ├── 处理:fs / crypto / zlib / dns.lookup
│   ├── 不走线程池:网络 I/O(epoll 多路复用,dns.lookup 除外)
│
├── 浏览器区别
│   ├── Task Queue + Microtask Queue vs 6 阶段
│   ├── Node 11+ 微任务时机行为趋同(底层仍有微小差异)
│   ├── process.nextTick / setImmediate 是 Node 独有
│   └── requestAnimationFrame 是浏览器独有
│
└── 避坑
    ├── nextTick 递归 → I/O starvation
    ├── UV_THREADPOOL_SIZE 不是越大越好
    └── 热路径别大量 nextTick,用 setImmediate

总结

问题 答案 验证实验
setTimeout vs setImmediate 谁先? 主模块不确定,I/O 回调里 setImmediate 先 实验1、2
nextTick vs Promise 谁先? nextTick 先 实验3
Node.js 是单线程吗? JS 单线程,libuv 有线程池 实验4
线程池越大越好吗? 不是,超过 CPU 核数反而慢 实验4
nextTick 递归会怎样? I/O starvation 实验5
浏览器和 Node.js 事件循环一样吗? 不一样,Task Queue + Microtask Queue vs 6 阶段 对比表

Node.js 事件循环的源码在 libuv 里(C 语言),但大部分情况下你不需要看 C 源码——用 JS 代码做实验就能观察到它的行为。这也是写这篇文章的初衷:每个结论都可以用 node 命令验证,不是背出来的

下一篇可以聊一下 Node.js Stream——为什么大数据量别用 fs.readFile,看看 Stream 的背压机制。

欢迎关注和讨论。