Node.js事件循环深度解析
一句话概括
Node.js 事件循环是基于 libuv 实现的异步 I/O 调度机制,它将 JavaScript 的单线程与底层 C++ 线程池结合,使得非阻塞 I/O 操作成为可能,本文详细剖析事件循环的六大阶段、各阶段的执行顺序以及与浏览器事件循环的核心差异。
背景与意义
「JavaScript 是单线程的」这句话每个前端开发者都耳熟能详。但 Node.js 却用它构建出了高性能的后端服务——每年处理数十亿请求的 npm 下载服务、每秒处理数万连接的实时聊天应用……单线程如何做到高并发?
答案就是事件循环(Event Loop)。它就像一个高度自律的调度官,严格按时间片和优先级把各种任务安排到单线程上执行,让 I/O 操作「等待不阻塞」。理解事件循环,是 Node.js 后端开发从「会用」到「精通」的分水岭。
为什么需要事件循环?
传统的多线程 I/O 模型(如 Apache 的每个请求一个线程)在面对大量并发连接时,线程的创建、切换和销毁会带来巨大的开销。Node.js 采用事件驱动 + 异步 I/O 的模型,用单线程配合事件循环来处理成千上万的并发连接,内存开销极低。
概念与定义
事件循环(Event Loop):一个持续运行的循环,负责从事件队列中取出回调函数并执行。它是 Node.js 异步行为的底层调度器。
libuv:一个专注于异步 I/O 的 C 语言库。Node.js 将其作为底层抽象层,提供事件循环、文件系统操作、网络 I/O、线程池等功能。
阶段(Phase):事件循环中的每个周期由多个阶段组成,每个阶段负责处理特定类型的回调。
微任务(Microtask/Microtask):在 ECMAScript 规范中定义,包括 Promise.then/catch/finally、queueMicrotask、process.nextTick(Node.js 特有)。微任务在当前阶段结束后立即执行。
核心知识点拆解
1. 事件循环的六大阶段
Node.js 的事件循环在 libuv 的实现中分为六个阶段,每个阶段维护各自的回调队列。一个完整的循环周期如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌───────────────────────────┐
┌─>│ timers │ ← setTimeout/setInterval 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ ← 上一轮延迟的 I/O 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ ← 内部使用
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ poll │ ← 轮询 I/O 事件,执行 I/O 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │ ← setImmediate 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ close callbacks │ ← socket.on('close') 等
│ └───────────────────────────┘
│ └────────────────────────────────┘
timers 阶段:执行 setTimeout 和 setInterval 的回调。Timer 的精度受轮询阶段影响,可能存在延迟。
pending callbacks 阶段:执行某些系统操作的回调,如 TCP 连接错误回调。大部分 I/O 回调在 poll 阶段处理。
idle, prepare 阶段:libuv 内部使用,开发者无需关注。
poll 阶段:最重要的阶段。负责轮询新的 I/O 事件,执行 I/O 相关回调(文件读写、网络请求等)。如果 poll 队列为空,会检查是否有 setImmediate 回调或定时器到期。
check 阶段:setImmediate() 回调在此执行。
close callbacks 阶段:执行关闭回调,如 socket.destroy() 的 close 事件。
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
29
30
31
32
33
34
35
36
37
// 演示事件循环各阶段的执行顺序
const fs = require('fs');
// timers 阶段
setTimeout(() => {
console.log('1. setTimeout (timers 阶段)');
}, 0);
// poll 阶段(I/O 回调)
fs.readFile(__filename, () => {
console.log('2. fs.readFile (poll 阶段)');
});
// check 阶段
setImmediate(() => {
console.log('3. setImmediate (check 阶段)');
});
// 微任务 - process.nextTick 在各个阶段之间执行
process.nextTick(() => {
console.log('4. process.nextTick (微任务)');
});
// 微任务 - Promise
Promise.resolve().then(() => {
console.log('5. Promise.then (微任务)');
});
console.log('0. 同步代码 (主线程)');
// 预期输出顺序:
// 0. 同步代码 (主线程)
// 4. process.nextTick (微任务)
// 5. Promise.then (微任务)
// 1. setTimeout (timers 阶段) ← 与 setImmediate 的顺序可能因启动时间而异
// 3. setImmediate (check 阶段)
// 2. fs.readFile (poll 阶段)
2. nextTick 与 Promise 的微任务机制
process.nextTick 是 Node.js 独有的机制,不属于事件循环的任何一个阶段,而是在每个阶段切换时执行。它的优先级甚至高于 Promise.then。
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
29
30
31
32
33
34
35
36
37
38
39
40
/**
* 演示 process.nextTick 与 Promise.then 的优先级关系
* 结论:nextTick 先于 Promise.then 执行
*/
function demonstrateMicrotasks() {
// 第 1 轮
process.nextTick(() => {
console.log('nextTick 1');
// 在第 1 个 nextTick 内又注册了第 3 个
process.nextTick(() => console.log('nextTick 3'));
});
process.nextTick(() => {
console.log('nextTick 2');
});
Promise.resolve().then(() => {
console.log('Promise 1');
});
Promise.resolve().then(() => {
console.log('Promise 2');
Promise.resolve().then(() => console.log('Promise 3'));
});
// 下一轮 - 这些会在所有微任务完成后执行
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
}
demonstrateMicrotasks();
// 输出顺序:
// nextTick 1
// nextTick 2
// nextTick 3
// Promise 1
// Promise 2
// Promise 3
// setTimeout (可能先)
// setImmediate (或这个先)
记住:process.nextTick 回调会形成一个队列,同一次阶段切换中,所有 nextTick 回调(包括递归注册的新 nextTick)会持续执行直到队列清空。因此递归 nextTick 可能导致 I/O 回调「饿死」。
3. setTimeout(fn, 0) 与 setImmediate 的执行顺序迷思
这是 Node.js 面试中高频出现的问题:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 场景一:在全局作用域中
* 输出不确定!setTimeout(fn, 0) 和 setImmediate 的顺序取决于系统性能
*/
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
/**
* 场景二:在 I/O 回调中
* setImmediate 一定先于 setTimeout 执行!
*/
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('setTimeout in I/O'), 0);
setImmediate(() => console.log('setImmediate in I/O'));
// 输出:setImmediate in I/O → setTimeout in I/O
});
原因分析:
- 在 I/O 回调中执行时,当前阶段是 poll 阶段
- 接下来事件循环进入 check 阶段,执行所有 setImmediate 回调
- 然后才进入下一轮的 timers 阶段,执行 setTimeout 回调
- 因此 I/O 回调内的 setImmediate 总是先于 setTimeout
在全局作用域中:
- 如果是主模块启动后立即执行,事件循环刚启动进入 timers 阶段
- setTimeout(fn, 0) 的延迟是 1ms(浏览器和 Node.js 的最小延迟)
- 如果 timer 已经到期,setTimeout 先;如果 timer 还没到期,setImmediate 先
- 结果取决于启动性能,所以不确定
4. poll 阶段的阻塞机制
poll 阶段的行为是事件循环最微妙的部分:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const fs = require('fs');
console.time('timer');
// 启动一个异步 I/O,耗时 50ms
fs.readFile('/large-file.dat', () => {
console.timeEnd('timer'); // 约 50ms
console.log('I/O 回调执行');
});
// 在 poll 阶段,事件循环发现 poll 队列为空
// 它会检查是否有到期的定时器:
// 有 → 跳到 timers 阶段
// 没有 → 等待 I/O 事件,但受以下因素限制:
// 1. 最近的定时器截止时间
// 2. 如果有 setImmediate,立即跳转到 check 阶段
//
// 这里 setTimeout 设置了 20ms
setTimeout(() => {
console.log('setTimeout 到期');
// 读取大文件需要 50ms,但 setTimeout 20ms 后就到期了
// 所以即使 poll 还在等 I/O,事件循环也会中断等待先去执行 timer
}, 20);
实战案例
实现一个简单的非阻塞调度器
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
/**
* 基于事件循环实现的任务调度器
* 演示事件循环各阶段的协作
*/
class EventLoopScheduler {
constructor() {
this.microTaskQueue = [];
this.timerQueue = [];
this.ioQueue = [];
this.checkQueue = [];
this.running = false;
}
// 模拟微任务(process.nextTick / Promise)
scheduleMicrotask(callback) {
this.microTaskQueue.push(callback);
this.run();
}
// 模拟定时器
scheduleTimer(callback, delayMs) {
this.timerQueue.push({
callback,
deadline: Date.now() + delayMs,
});
this.run();
}
// 模拟 I/O 操作
scheduleIO(callback, delayMs) {
setTimeout(() => {
this.ioQueue.push(callback);
this.run();
}, delayMs);
}
// 模拟 setImmediate
scheduleImmediate(callback) {
this.checkQueue.push(callback);
this.run();
}
run() {
if (this.running) return;
this.running = true;
// 模拟事件循环主循环
const maxLoops = 3;
for (let loop = 1; loop <= maxLoops; loop++) {
console.log(`\n=== Event Loop 第 ${loop} 轮 ===`);
// 1. timers 阶段
const now = Date.now();
const expiredTimers = this.timerQueue.filter(t => t.deadline <= now);
this.timerQueue = this.timerQueue.filter(t => t.deadline > now);
for (const timer of expiredTimers) {
timer.callback();
this.flushMicrotasks();
}
// 2. I/O callbacks (简化为 poll)
while (this.ioQueue.length > 0) {
const callback = this.ioQueue.shift();
callback();
this.flushMicrotasks();
}
// 3. check 阶段
while (this.checkQueue.length > 0) {
const callback = this.checkQueue.shift();
callback();
this.flushMicrotasks();
}
}
this.running = false;
}
flushMicrotasks() {
while (this.microTaskQueue.length > 0) {
const callback = this.microTaskQueue.shift();
callback();
}
}
}
// 使用示例
const scheduler = new EventLoopScheduler();
scheduler.scheduleTimer(() => console.log('Timer 1'), 100);
scheduler.scheduleImmediate(() => console.log('Immediate 1'));
scheduler.scheduleMicrotask(() => console.log('Microtask 1'));
实战:用事件循环分析性能问题
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
* 阻塞事件循环的典型陷阱
*/
function eventLoopBlocker() {
// 陷阱 1:同步 CPU 密集型操作阻塞事件循环
function heavyComputation() {
let sum = 0;
for (let i = 0; i < 1e9; i++) {
sum += Math.sqrt(i);
}
return sum;
}
console.log('开始耗时计算...');
setTimeout(() => console.log('这个 setTimeout 会被严重延迟!'), 0);
const result = heavyComputation(); // 阻塞事件循环 ~500ms
console.log('计算完成:', result);
// setTimeout 回调在 heavyComputation 完成后才执行
// 陷阱 2:递归 nextTick 饿死 I/O
function recursiveNextTick() {
process.nextTick(() => {
console.log('不断排队的 nextTick');
recursiveNextTick(); // 递归注册,永不停止
});
}
// recursiveNextTick(); // ⚠️ 别取消注释,会饿死事件循环
// 正确做法:将 CPU 密集型任务拆分或使用 Worker Threads
function nonBlockingComputation() {
const chunkSize = 1000000;
let sum = 0;
let i = 0;
function processChunk() {
const end = Math.min(i + chunkSize, 1e9);
for (; i < end; i++) {
sum += Math.sqrt(i);
}
if (i < 1e9) {
setImmediate(processChunk); // 让出事件循环
} else {
console.log('非阻塞计算完成:', sum);
}
}
setImmediate(processChunk);
}
}
底层原理
libuv 的事件循环源码简析
libuv 的事件循环核心实现在 src/unix/core.c 的 uv_run() 函数中。简化后的伪代码如下:
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
29
30
31
32
// libuv 事件循环核心(伪代码)
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
while (UV_RUN_ONCE || UV_RUN_DEFAULT) {
// 1. 更新当前时间(缓存时间,减少 gettimeofday 调用)
uv_update_time(loop);
// 2. timers 阶段:遍历 timer 堆,执行到期回调
uv__run_timers(loop);
// 3. pending callbacks:执行上轮延迟的回调
uv__run_pending(loop);
// 4. idle/prepare:内部回调
uv__run_idle(loop);
uv__run_prepare(loop);
// 5. poll 阶段:核心 I/O 事件轮询
// timeout 计算规则:
// - 有 pending 回调 → timeout = 0(不等待)
// - 有即将到期的 timer → timeout = 剩余时间
// - setImmediate 等待中 → timeout = 0
// - 无任何待办 → timeout = -1(无限等待)
timeout = uv__next_timeout(loop);
uv__io_poll(loop, timeout);
// 6. check 阶段:执行 setImmediate
uv__run_check(loop);
// 7. close callbacks
uv__run_closing_handles(loop);
}
}
关键要点:
- 单线程中的并发:事件循环本身运行在主线程上,但 I/O 操作由 libuv 的线程池(默认 4 个线程)执行
- 非阻塞的本质:I/O 调用立即返回,内核或线程池在后台处理,完成后通过事件通知事件循环
- timeout 计算策略:poll 阶段的阻塞时间由最近的定时器决定,这是确保
setTimeout精度的关键
Node.js 与浏览器事件循环的区别
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 关键区别演示
*/
// 区别 1:Node.js 有 process.nextTick,浏览器没有
// 区别 2:Node.js 有 setImmediate,浏览器没有对应的 API
// 区别 3:Node.js 的微任务在每个阶段之间执行,浏览器在整个宏任务之后执行
// Node.js 中的行为:
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('Promise'));
setTimeout(() => console.log('setTimeout'), 0);
// Node.js 输出:nextTick → Promise → setTimeout
// 浏览器中的行为:
Promise.resolve().then(() => console.log('Promise'));
setTimeout(() => console.log('setTimeout'), 0);
// 浏览器输出:Promise → setTimeout
// 注意:nextTick 不存在
核心差异表:
| 特性 | Node.js | 浏览器 |
|---|---|---|
| 微任务执行时机 | 每个阶段切换时 | 每个宏任务结束后 |
| process.nextTick | ✅ 有,优先级 > Promise | ❌ 无 |
| setImmediate | ✅ 有 | ❌ 无(类似的有 postMessage) |
| 微任务队列数 | 至少 2 个(nextTick + 其他) | 1 个 |
| 主线程 | JavaScript + libuv 线程池 | JavaScript + Web API |
| 渲染时机 | 无渲染阶段 | 有渲染阶段(requestAnimationFrame) |
高频面试题解析
面试题1:这段代码的输出顺序是什么?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
new Promise(resolve => {
console.log('promise1');
resolve();
}).then(() => console.log('promise2'));
process.nextTick(() => console.log('nextTick'));
console.log('script end');
答案:
1
2
3
4
5
6
7
8
9
script start
async1 start
async2
promise1
script end
nextTick
async1 end
promise2
setTimeout
解析:await async2() 后面的代码相当于 .then() 微任务。process.nextTick 优先级高于 Promise.then。所以 nextTick 先于 async1 end 和 promise2 执行。
面试题2:如何实现一个 sleep 函数且不阻塞事件循环?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ❌ 错误:同步阻塞
function badSleep(ms) {
const start = Date.now();
while (Date.now() - start < ms) {} // 阻塞事件循环!
}
// ✅ 正确:返回 Promise
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// 使用
async function demo() {
console.log('开始');
await sleep(2000); // 不阻塞事件循环
console.log('2秒后');
}
面试题3:为什么 process.nextTick 比 Promise.then 先执行?
答:这是 Node.js 的设计选择。在事件循环的 C++ 层实现中,process.nextTick 队列在 libuv 的 uv__run_timers 之后检查,而 Promise 回调由 V8 的 MicrotaskQueue 管理。Node.js 在处理完每个 C++ 回调后先清空 nextTick 队列,再清空微任务队列,因此 nextTick 的优先级最高。
面试题4:Node.js 中什么样的操作会进入线程池?
答:DNS 的 dns.lookup()、文件系统 I/O(如 fs.readFile)、某些加密操作(crypto.pbkdf2、crypto.randomBytes)等。网络 I/O(TCP/UDP/HTTP)则使用操作系统原生的异步 I/O(如 epoll/kqueue),不占用线程池。
面试题5:如何让 CPU 密集型计算不阻塞事件循环?
答:三种策略:
- 拆分任务:用
setImmediate或nextTick将大量计算拆分成小块轮番执行 - 使用
child_process:fork 子进程来处理 - 使用
worker_threads:创建工作线程,这是 Node.js 推荐的方案(自 v10.5.0 起)
总结与扩展
理解事件循环是掌握 Node.js 的基石。核心要点:
- 六大阶段:timers → pending → poll → check → close,每轮循环清空各阶段队列
- 微任务挂载点:nextTick 和 Promise 在每个阶段切换时执行,优先级依次为 nextTick > Promise
- poll 阶段最重要:它决定了 I/O 回调的执行时机和事件循环的阻塞时间
- 与浏览器的区别:多了 nextTick 和 setImmediate,微任务执行时机更细粒度
扩展方向:
- process.nextTick 的递归陷阱:递归 nextTick 会导致 I/O 回调永远得不到执行,使用
setImmediate递归更安全 - timers 精度:Node.js 的定时器精度受事件循环负载影响,高精度定时任务应使用
perf_hooks或专门方案 - 事件循环的诊断:使用
NODE_DEBUG=libuv环境变量可以查看事件循环内部日志 - libuv 线程池大小调整:
process.env.UV_THREADPOOL_SIZE可调整线程池大小(最大 1024),但过大可能适得其反