Node.js 事件循环 (Event Loop) 核心笔记
核心悖论与答案
问题:Node.js 如何用单线程处理数万并发连接?
答案:事件循环 (Event Loop)。它不是魔法,而是一个由 C 库 Libuv 实现的精密机制,通过非阻塞 I/O 和异步回调来实现高并发。
1. 根基:调用栈 (Call Stack) 与阻塞 (Blocking)
调用栈
-
机制:JavaScript 是单线程的,拥有一个唯一的调用栈(LIFO - 后进先出结构)。函数调用被压入栈,执行完毕后被弹出。
-
重要性:任何在栈顶的函数都会独占 CPU,直到它返回。
阻塞的真正含义
-
定义:一个长时间占用调用栈的函数会阻止其他任何代码执行,包括异步回调的处理。整个进程会被“卡住”。
-
示例:同步的 CPU 密集型操作(如
crypto.pbkdf2Sync循环)或大型同步 I/O(如fs.readFileSync)会阻塞事件循环。-
即使
setTimeout的回调已到期,它也必须等待调用栈清空后才能被执行。
-
2. 核心架构:V8, Libuv 与 Node.js 绑定
Node.js 运行时是多个组件的协作:
| 组件 | 角色 | 职责 |
|---|---|---|
| V8 | JS 引擎 | 执行 JS 代码、管理调用栈和堆内存、进行 JIT 编译。它本身不了解 I/O、定时器等。 |
| Libuv | 异步 I/O 库 | 实现事件循环、抽象不同操作系统的异步 I/O 机制(epoll, kqueue, IOCP)、管理线程池。 |
| Node Bindings | 胶水层 | 用 C++ 编写,作为桥梁,将 Node.js API(如 fs.readFile)的调用从 V8 翻译并转发给 Libuv。 |
Libuv 的线程池 (Thread Pool)
-
目的:处理操作系统本身不支持异步的阻塞型操作(如部分文件系统操作、DNS 查找、CPU 密集型加密任务)。
-
默认大小:4 个线程。可通过环境变量
UV_THREADPOOL_SIZE调整。 -
影响:所有使用线程池的操作共享这个有限的资源。如果一个操作阻塞了线程池线程,其他需要线程池的操作就会被排队等待,可能导致意想不到的瓶颈。
一个异步操作的旅程
例如 fs.readFile('/path', callback):
-
JS 调用
fs.readFile。 -
Node 绑定 接收调用,准备参数。
-
Libuv 将任务提交到线程池。
-
线程池中的一个线程执行阻塞的
read系统调用。 -
操作完成,线程将回调事件放入队列。
-
事件循环(在 Poll 阶段)获取到该事件。
-
最终,回调函数被推送到 V8 的调用栈上执行。
3. 事件循环的六个阶段 (Phases)
事件循环的一次完整迭代称为一个 "tick",它按顺序处理以下六个阶段。每个阶段都有一个 FIFO(先进先出)的回调队列。
完成后:循环检查是否还有任何活动的句柄(定时器、I/O 等)。如果有,则进入下一个 tick(回到 Timers 阶段);如果没有,则进程退出。
-
Timers(定时器阶段)
-
执行
setTimeout()和setInterval()的回调。 -
注意:回调的执行时间是预定的最小延迟,而非精确时间。它受正在执行的操作耗时影响。
-
-
Pending callbacks(待定回调阶段)
-
执行延迟到下一个循环迭代的 I/O 回调(如某些系统错误回调)。
-
-
Idle, Prepare(闲置、准备阶段)
-
Libuv 内部使用的阶段。
-
-
Poll(轮询阶段) - *最重要阶段*
-
两个主要功能:
-
计算应阻塞并等待 I/O 的时间。
-
处理轮询队列中的事件(执行 I/O 回调,如网络请求、文件读取完成)。
-
-
逻辑:
-
如果轮询队列不为空:循环执行回调直到队列清空或达到系统限制。
-
如果轮询队列为空:
-
如果有
setImmediate()调度,则结束 Poll 阶段,进入 Check 阶段。 -
如果没有,则事件循环将在此等待新的 I/O 事件到达。
-
-
-
-
Check(检查阶段)
-
专门执行
setImmediate()的回调。
-
-
Close callbacks(关闭回调阶段)
-
执行关闭事件的回调(如
socket.on('close', ...))。
-
4. 微任务 (Microtasks) 与宏任务 (Macrotasks)
这是理解执行顺序的关键。
| 类型 | 包含 | 优先级与规则 |
|---|---|---|
| 宏任务 (Macrotask) | setTimeout, setInterval, setImmediate, I/O 回调 | 在事件循环的各个阶段中执行。 |
| 微任务 (Microtask) | process.nextTick(), Promise 回调 (.then/.catch/.finally), queueMicrotask(), async/await | 不在事件循环阶段中执行。它们拥有更高的优先级。 |
黄金法则
在每一个宏任务执行完毕后、移动到下一个事件循环阶段之前,事件循环会立即清空整个微任务队列(包括 nextTick 和 Promise)。
微任务队列的优先级
-
process.nextTick()队列:最高优先级。在当前操作完成后立即执行。 -
Promise 回调队列:次高优先级。
危险:微任务饥饿 (Starvation)
如果在 process.nextTick() 或 Promise 回调中递归地安排新的微任务,会导致事件循环永远无法离开当前阶段去处理宏任务(如 I/O、定时器),从而使程序挂起。
// 错误示例:这将阻塞事件循环!
function starve() {
process.nextTick(starve); // 不断往微任务队列添加任务
}
starve();
setTimeout(() => console.log('Never runs'), 0);
5. 执行顺序实战分析
const fs = require('fs');
console.log('1. Start');
setTimeout(() => console.log('2. Timeout'), 0);
Promise.resolve().then(() => console.log('3. Promise'));
process.nextTick(() => console.log('4. nextTick'));
fs.readFile(__filename, () => {
console.log('5. I/O Callback');
setImmediate(() => console.log('6. Immediate'));
process.nextTick(() => console.log('7. nextTick'));
Promise.resolve().then(() => console.log('8. Promise'));
});
console.log('9. End');
// 输出顺序: 1, 9, 4, 3, 2, 5, 7, 8, 6
步骤解析:
-
同步代码:输出
1,9。 -
清空微任务:
4 (nextTick),3 (Promise)。 -
事件循环开始:
-
Timers 阶段:执行
2 (Timeout)。 -
...其他阶段...
-
Poll 阶段:文件读取完成,执行其回调
5 (I/O Callback)。-
在这个宏任务内部:又安排了
setImmediate(宏任务) 和微任务。 -
这个宏任务执行完:立即清空其产生的微任务:
7 (nextTick),8 (Promise)。
-
-
事件循环继续到 Check 阶段:执行
6 (Immediate)。
-
6. 性能、模式与陷阱
常见的阻塞源
-
CPU 密集型计算:长循环、复杂算法。
-
同步 I/O:
fs.readFileSync等。 -
不明显的阻塞:
-
大型 JSON 操作 (
JSON.parse/JSON.stringify)。 -
复杂的正则表达式(可能导致灾难性回溯)。
-
垃圾回收 (GC) 大型操作可能引发长时间的“Stop-the-World”暂停。
-
Libuv 线程池瓶颈
记住,线程池是共享且有限的。大量并发的文件操作、DNS 查询或 Crypto 操作可能会因为等待线程池线程而出现性能瓶颈。
如何监控事件循环延迟
-
简单方案:用
setInterval检查实际延迟。setInterval(() => { const now = Date.now(); const delay = now - lastCheck - 1000; if (delay > 100) console.warn(`Event loop delayed: ${delay}ms`); lastCheck = now; }, 1000); -
推荐方案:使用
perf_hooks.monitorEventLoopDelay。const { monitorEventLoopDelay } = require('perf_hooks'); const histogram = monitorEventLoopDelay(); histogram.enable(); // ...定期读取 histogram.mean 查看平均延迟...
7. 处理 CPU 密集型任务:策略
1. 拆分任务 (Offloading to the Loop)
将大任务拆分成小块,用 setImmediate 或 setTimeout 在块之间让步于事件循环,保持应用响应。
function processChunk() {
// 处理一小部分数据...
if (moreToDo) setImmediate(processChunk); // 而不是同步循环
}
2. 使用 Worker Threads (真正并行)
worker_threads 模块可以创建真正的并行线程,每个线程有独立的 V8 实例和事件循环。用于处理重型计算,不阻塞主线程。
-
通信:通过消息传递 (
postMessage),内存默认不共享。 -
与集群 (Cluster) 区别:
-
cluster:用于扩展网络服务(多进程),共享端口,负载均衡。 -
worker_threads:用于卸载重型计算任务(多线程)。
-
8. 常见误区
setTimeout(fn, 0) vs setImmediate()
-
在顶层代码中:执行顺序不确定。取决于系统状态(例如,如果准备时间超过 1ms,定时器先到期;否则先进入 Check 阶段)。
-
在 I/O 回调内部:顺序确定。
setImmediate总是先执行,因为它在一个 tick 的 Poll 阶段之后、Check 阶段执行;而setTimeout要等到下一个 tick 的 Timers 阶段。
// 在 I/O 回调内,顺序总是固定的:
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate')); // 这个先输出
});
总结与核心思想
掌握事件循环是关于构建一个准确的心智模型,从而能够:
编写高性能、非阻塞的代码。
准确预测异步代码的执行顺序。
最重要的收获:
-
不要阻塞调用栈。
-
理解微任务优先于宏任务。
-
记住 Libuv 线程池是共享资源。
-
对于重型 CPU 任务,使用 Worker Threads。
-
有效地调试性能瓶颈和奇怪的行为。
-
更多推荐

所有评论(0)