很多人第一次接触 Node.js 事件循环 时都会困惑:JavaScript 明明是单线程,为什么 Node.js 能在不增加线程的情况下处理成千上万的并发请求?答案就藏在 事件循环(Event Loop) 里。它并不是一个简单的 while 循环,而是一套由 libuv 驱动、分阶段调度”宏任务”与”微任务”的精密机制。本文用可运行的代码把这套机制讲透,并给出生产环境避免阻塞、利用多核的落地方案。
一、为什么单线程的 Node.js 能扛高并发
Node.js 的”单线程”指的是 执行 JavaScript 的主线程只有一个,但底层并非真的只有一根线。文件读写、网络请求、定时器这些耗时操作,都被交给了 libuv 的 线程池 和操作系统异步接口(如 epoll/kqueue/iocp)去处理。主线程只负责”调度”和”执行回调”,I/O 等待期间它不会被卡住,于是看起来就像在同时服务大量连接。
理解这一点很关键:事件循环的本质是 用异步 I/O 换吞吐。它擅长处理大量”等待型”任务(HTTP 请求、数据库查询、文件读取),却怕”计算型”长任务——因为一旦主线程陷入重计算,整个循环就被堵死。后文会专门讲怎么破这个局。
二、事件循环不是”循环”那么简单:六大阶段
libuv 把一次完整的事件循环拆成若干个有序阶段(phase),每个阶段都有自己专门的回调队列。简化后核心顺序如下:
- timers:执行 setTimeout / setInterval 到期的回调。
- pending callbacks:执行上一轮被推迟的 I/O 回调(如 TCP 错误)。
- idle / prepare:内部使用,应用层基本无感。
- poll:最核心的阶段,取出新的 I/O 事件(网络、文件)并执行回调;如果没有定时器,会在此处适当等待新事件。
- check:执行 setImmediate 的回调。
- close callbacks:执行 close 类事件(如 socket.on(‘close’))。
每跑完一个阶段,Node.js 都会先把当前阶段的队列清空,再去处理 微任务队列(后面详述),然后再进入下一阶段。这就是为什么同样是异步,执行顺序却有章可循。
2.1 timers 阶段与 poll 阶段的博弈
setTimeout(fn, 0) 并不是”立刻”,而是”最早在 timers 阶段执行”。如果 poll 阶段正卡在大量 I/O 回调里,timer 的触发就会被延后。这也解释了为什么在高负载服务里,setTimeout 的实际延迟往往大于设定值。
三、宏任务 vs 微任务:执行顺序的真相
事件循环里存在两套队列:宏任务(Macrotask) 队列和 微任务(Microtask) 队列。一个阶段处理完队列后,会先把 所有微任务清空,才继续下一阶段。Promise 的 .then / .catch / .finally、queueMicrotask 都属于微任务。
console.log("1. 同步开始");
setTimeout(() => console.log("4. setTimeout 宏任务"), 0);
Promise.resolve()
.then(() => console.log("3. Promise 微任务"));
process.nextTick(() => console.log("2.5 nextTick(先于微任务)"));
console.log("2. 同步结束");
// 输出顺序:
// 1. 同步开始
// 2. 同步结束
// 2.5 nextTick(先于微任务)
// 3. Promise 微任务
// 4. setTimeout 宏任务
3.1 微任务队列:Promise 与 queueMicrotask
微任务会在”当前宏任务结束、下一个宏任务开始前”被全部执行完。也就是说,只要微任务里不断产生新的微任务,就能一直占用主线程,导致后续宏任务饥饿。写代码时要避免微任务无限递归。
3.2 process.nextTick 的特殊地位
process.nextTick 不属于事件循环的任何阶段,它有自己独立的 nextTick 队列,且优先级高于 Promise 微任务——每个阶段切换前都会先清空 nextTick 队列。滥用 nextTick 递归同样会”饿死”事件循环,所以官方建议优先用 queueMicrotask。
3.3 一个经典面试题拆解
setTimeout(() => console.log("A"), 0);
setImmediate(() => console.log("B"));
// 在 I/O 回调里,setImmediate 通常先于 setTimeout 执行:
require("fs").readFile(__filename, () => {
setTimeout(() => console.log("A-in-io"), 0);
setImmediate(() => console.log("B-in-io")); // 先打印
});
原因在于:poll 阶段结束后进入 check 阶段执行 setImmediate,之后再回到下一轮的 timers 阶段执行 setTimeout。所以 在 I/O 回调内部,setImmediate 几乎总是早于 setTimeout;而在最外层(无 I/O 包裹)时两者顺序不确定,取决于进入循环时的耗时。
四、从代码看调度:5 个可运行示例
下面几个片段都能直接保存为 .js 运行,建议亲手跑一遍,对照输出理解调度节奏。
// 示例1:微任务插队
Promise.resolve().then(() => console.log("micro-1"));
queueMicrotask(() => console.log("micro-2"));
setTimeout(() => console.log("macro-1"), 0);
console.log("sync");
// sync -> micro-1 -> micro-2 -> macro-1
// 示例2:nextTick 先于 Promise
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
// 先 nextTick,再 promise
// 示例3:I/O 回调里的顺序
const fs = require("fs");
fs.readFile(__filename, () => {
setImmediate(() => console.log("check 阶段"));
setTimeout(() => console.log("timers 阶段"), 0);
});
把这些示例和前面”六大阶段”对照,你会发现所有”反直觉”的输出,其实都被阶段顺序严格决定。
五、对比浏览器事件循环
很多前端同学会把两者混为一谈。其实浏览器事件循环由 HTML 规范定义,多一个 渲染(render) 时机;Node.js 事件循环由 libuv 定义,多了 poll、check 等 I/O 相关阶段。关于浏览器侧的宏任务/微任务与渲染时机,可延伸阅读 浏览器事件循环详解。两者对”微任务优先于下一轮宏任务”这一点的共识是一致的。
| 维度 | Node.js 事件循环 | 浏览器事件循环 |
|---|---|---|
| 驱动方 | libuv | HTML 规范 / 浏览器内核 |
| 核心阶段 | timers/poll/check/close 等 | 宏任务队列 + 渲染时机 |
| 微任务 | Promise、queueMicrotask、nextTick | Promise、queueMicrotask |
| 特殊队列 | process.nextTick 队列 | 无 |
| 渲染 | 不涉及 | 每轮宏任务后可能重绘 |
六、事件循环与性能:别阻塞它
事件循环最大的敌人是 同步长任务。下面的代码在处理大数组时会让整服务”假死”,期间所有请求都无法响应。
// 反例:阻塞主线程
app.get("/compute", (req, res) => {
let sum = 0;
for (let i = 0; i < 5e9; i++) sum += i; // 几十亿次循环
res.send(String(sum));
});
6.1 用 cluster 利用多核
Node.js 默认只用到一个 CPU 核心。用 cluster 模块可以 Fork 出与核数相等的 worker,让事件循环在多个进程上并行:
const cluster = require("cluster");
const os = require("os");
if (cluster.isPrimary) {
const cpus = os.cpus().length;
for (let i = 0; i < cpus; i++) cluster.fork();
} else {
require("./app"); // 每个 worker 独立跑事件循环
}
6.2 用 Worker 线程卸载重计算
如果不想多进程,可以用 worker_threads 把重计算搬到独立线程,主事件循环保持轻量:
const { Worker } = require("worker_threads");
const w = new Worker(`
const { parentPort } = require("worker_threads");
let sum = 0;
for (let i = 0; i < 5e9; i++) sum += i;
parentPort.postMessage(sum);
`, { eval: true });
w.on("message", (r) => console.log("重计算结果:", r));
七、生产排障:卡顿与事件循环延迟监控
当接口偶发卡顿,很可能就是事件循环被某个长任务阻塞。用 perf_hooks 监控”循环延迟(lag)”能提前发现苗头:
const { monitorEventLoopDelay } = require("perf_hooks");
const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();
setInterval(() => {
if (h.max > 100) {
console.warn("事件循环延迟过高:", h.max.toFixed(1), "ms");
}
}, 1000);
| 阻塞源 | 典型场景 | 对策 |
|---|---|---|
| 同步重计算 | 大循环、复杂加解密 | Worker 线程 / 拆分任务 |
| 同步 I/O | readFileSync、JSON.parse 大文件 | 改用异步 API / 流式 |
| 微任务递归 | Promise 链无限追加 | 改为 setTimeout 切分 |
| 大对象 GC 压力 | 缓存无上限 | 限容 + LRU |
把 Node.js 服务用 Docker 容器化后(参考 Docker 实战),配合 GitHub Actions 做 CI,再用 Hono 这类轻量框架写接口,事件循环能一直保持清爽。若想尝试更快的运行时,也可以看看 Bun 或 Deno。需要实时双向通信时,WebSocket 实战 也是基于同一套事件循环模型。
八、落地 Checklist
- 遇到 I/O 优先用异步 API,杜绝 readFileSync / 大循环阻塞主线程。
- 理解宏任务/微任务顺序,避免 nextTick 或 Promise 递归导致饥饿。
- CPU 密集任务用 cluster 或 worker_threads 卸载。
- 上线前接入事件循环延迟监控,设定告警阈值。
- 长列表 / 大 JSON 用流式或分页,缩小单次主线程占用。
事件循环是 Node.js 高性能的底层密码。把它想清楚,你写的每一行异步代码,都会知道它到底”什么时候、在哪个阶段”被执行。这正是单线程也能扛高并发的真正原因。




