Node.js 事件循环
上篇 Node.js async await 把异步写法讲完了,但有个根本问题还没回答,都说 Node.js 单线程,凭什么扛得住高并发。这篇讲事件循环,是全教程最难点,也是面试高频题。理解它之后,写定时器、I/O 代码都能预判执行顺序,排查诡异问题也有方向。
核心问题
单线程的 JS 一次只能做一件事,磁盘读写、网络请求又慢又不可控。如果这些都在主线程上等,一个请求就能卡住整个服务。Node.js 的解法是,把耗时的 I/O 交给底层库去做,主线程只负责调度,哪件事完成了就把它的后续代码捡起来执行。这个调度机制就是事件循环。
Node.js 架构
Node.js 由两部分组成,上层是 V8 引擎,负责执行 JS 代码,下层是 libuv 库,负责事件循环和线程池。libuv 用 C 语言实现,屏蔽了操作系统差异,Windows 上用 IOCP,macOS 和 Linux 上用 epoll/kqueue,对 JS 层完全透明。
node -e "console.log(process.versions.libuv)"
这条命令能查看当前 Node 自带的 libuv 版本,环境搭建方法见 Node.js 环境搭建。
单线程 vs 非阻塞 I/O
JS 确实只有一个主线程,但不代表所有工作都在这一个线程上。fs 读写、DNS 查询这类 I/O 操作,会被 libuv 派到线程池执行,一个操作占一个线程,线程池默认 4 个,可以通过 UV_THREADPOOL_SIZE 环境变量调整。I/O 完成后,libuv 把结果和回调打包,放进事件循环的队列,主线程忙完手头的事就过来处理。
结论是,JS 代码的执行是单线程的,但 I/O 是并行发生的。CPU 计算仍然单线程,一个密集的 for 循环照样会卡住整个进程。
看一个具体例子,感受单线程下 I/O 怎么并行
const fs = require("node:fs");
console.log("开始读文件");
fs.readFile("big.txt", "utf8", (err, data) => {
console.log("文件读完了", data.length);
});
console.log("主线程继续干活");
输出顺序是”开始读文件”、”主线程继续干活”、”文件读完了”。前两行立即打印,readFile 在 libuv 线程池里干活,完成后回调才被事件循环捡起来执行,主线程全程没有等待。
事件循环六个阶段
一轮循环按固定顺序走六个阶段
| 阶段 | 职责 |
|---|---|
| Timers | 执行到期的 setTimeout 和 setInterval 回调 |
| Pending Callbacks | 执行上一轮遗留的系统级回调,比如 TCP 连接错误 |
| Idle/Prepare | libuv 内部使用,JS 代码接触不到 |
| Poll | I/O 回调主要在这里执行,空闲时等待新事件 |
| Check | 执行 setImmediate 回调 |
| Close Callbacks | 处理 socket 等资源的 close 事件 |
一轮走完再进下一轮,直到没有任务可做,进程退出。各阶段的任务队列空了就跳过,不会干等。
举一轮循环的例子。假设同时注册了 1 个定时器和 1 个读文件回调,循环先进 Timers 执行定时器,再穿过 Pending、Idle/Prepare,到 Poll 阶段发现文件读完了,执行它的回调,然后进 Check 跑 setImmediate,最后到 Close Callbacks 处理关闭事件,再回到 Timers 开始下一轮。循环里新注册的定时器不会插队,要等下一轮才轮到。
宏任务 vs 微任务
上面六个阶段里执行的都是宏任务,比如定时器回调、I/O 回调。微任务则是另一套,包括 process.nextTick 和 Promise.then,它们不在任何阶段里,而是在每个阶段切换时被清空。
微任务内部还有先后,process.nextTick 的队列永远先于 Promise 微任务执行,这是 Node 的硬性规定,与标准微任务不同,nextTick 是 Node 独有的。写代码时应该优先用 Promise,nextTick 留给框架内部使用,比如在事件触发前修正状态。
用一小段代码验证微任务先于宏任务
setTimeout(() => console.log("宏任务"), 0);
Promise.resolve().then(() => console.log("微任务"));
输出顺序是”微任务”在前,”宏任务”在后。微任务在阶段切换时被清空,宏任务要等对应阶段到来,微任务的优先级天然更高。
执行顺序验证
把四类任务凑在一起,观察输出
console.log("1 同步代码");
process.nextTick(() => console.log("2 nextTick"));
Promise.resolve().then(() => console.log("3 Promise.then"));
setTimeout(() => console.log("4 setTimeout"), 0);
setImmediate(() => console.log("5 setImmediate"));
console.log("6 同步代码结尾");
输出顺序是 1、6、2、3,然后是 4 和 5。逐行解释
| 输出 | 原因 |
|---|---|
| 1、6 | 同步代码先全部执行完,主模块结束时事件循环才启动 |
| 2 | 主模块一结束,先清空 nextTick 队列 |
| 3 | 接着清空 Promise 微任务队列 |
| 4、5 | 进入 Timers 阶段执行 setTimeout,走到 Check 阶段执行 setImmediate |
4 和 5 的先后在主模块里不固定,取决于机器负载。但把同样两行写进一个 I/O 回调里,setImmediate 永远先于 setTimeout,因为 I/O 回调在 Poll 阶段执行,下一步紧跟着就是 Check 阶段,而 setTimeout 要等下一轮 Timers。这个区别是面试常考点。
为什么理解事件循环重要
写定时器能预判触发时机,比如 setTimeout 设 0 毫秒也不是立刻执行,得等同步代码跑完再进 Timers 阶段。排查卡顿问题时,知道 CPU 密集代码会阻塞所有阶段,就不会去怀疑网络。
进程退出规则也藏在事件循环里。事件循环没任务可做时进程才退出,只要还有句柄挂着,比如服务器监听、未到期的定时器、进行中的 I/O,进程就一直活着。这就是为什么 http 服务器一启动就常驻不退出,也是内存泄漏排查时”进程为什么退不掉”的答案。
定时器也是句柄,验证一下
const timer = setTimeout(() => console.log("定时器触发"), 5000);
运行后进程会挂 5 秒不退出,定时器触发、回调执行完,事件循环没有任务可做,进程立刻退出。反过来,想主动放掉句柄可以用 clearTimeout 取消定时器,进程就会提前退出。
实践
第一步,把上面的顺序验证代码存成 loop-order.js 运行,对着表格核对输出。再改一处,把 setImmediate 和 setTimeout 挪进 fs.readFile 的回调里
const fs = require("node:fs");
fs.readFile(__filename, "utf8", () => {
setTimeout(() => console.log("setTimeout"), 0);
setImmediate(() => console.log("setImmediate"));
});
运行几次,确认 setImmediate 每次都在 setTimeout 前面,这正是 Check 阶段紧跟 Poll 阶段的证据。
第二步,验证进程退出规则。写一个普通脚本
console.log("任务完成");
运行后进程立即退出。再写一个带服务器监听的脚本
const http = require("node:http");
const server = http.createServer((req, res) => {
res.end("hello");
});
server.listen(3000, () => {
console.log("服务器监听 3000 端口");
});
运行后进程一直不退出,因为 listen 挂着句柄,事件循环每轮都在 Poll 阶段等新连接。打开另一个终端访问
curl http://localhost:3000
看到 hello 响应后,按 Ctrl+C 结束进程。
第三步,验证微任务饥饿,在 nextTick 里递归调用 nextTick,观察 setTimeout 的回调是否迟迟不执行
process.nextTick(function tick() {
process.nextTick(tick);
});
setTimeout(() => console.log("定时器终于执行了"), 100);
nextTick 队列永远清不完,Timers 阶段一直进不去,这就是微任务把事件循环饿死了,生产代码里禁止这样写。
常见坑
第一个坑,把 setTimeout 当精确计时器。它只保证不早于设定时间触发,事件循环忙时可能晚很多,做倒计时要记录真实时间差。
第二个坑,在 nextTick 或 Promise 里做无限递归,微任务队列饿死事件循环,定时器和 I/O 全部瘫痪,进程看似卡死。
第三个坑,以为 setTimeout(0) 最快。同步代码之后最先跑的是微任务,nextTick 又排在 Promise 前面,想”尽快”执行要看清楚目标场景。
第四个坑,CPU 密集任务写在主线程。一个百万次的循环会让所有请求排队,应该拆成小块用 setImmediate 让出时间片,或者交给 worker_threads 子线程。
下一步
事件循环是 Node.js 的地基,地基打牢,后面就可以搭建应用层了。下一章 Node.js Express 入门 用它处理 HTTP 请求,写第一个真正的 Web 服务。