Node.js 中的事件循环:工作机制和实际例子
1. 概览
事件循环是帮助 Node.js 用单一 JavaScript 线程处理数千个并发请求的核心机制。很多开发者每天写 async/await、setTimeout、Promise 代码,但并不清楚实际的执行顺序是如何进行的。
这篇文章通过具体的阶段解释事件循环的结构,区分 microtask 和 macrotask,然后用可运行的代码演示。读完后,你将能准确预测复杂异步代码的日志执行顺序,并避免像阻塞事件循环这样的常见错误。
2. Node.js 如何以单线程方式运行
Node.js 在一个主线程上运行 JavaScript,但 I/O 任务(读取文件、网络调用、数据库查询)被推送到 libuv——一个处理异步 I/O 的 C 库,使用线程池或内核机制(epoll、kqueue 等)。
┌───────────────────────────┐
│ JavaScript (single thread) │
└──────────────┬────────────┘
│ 调用 fs.readFile、setTimeout、net...
▼
┌───────────────────────────┐
│ libuv (C/C++) │
│ - 线程池 │
│ - 事件循环 │
│ - OS 异步 API │
└───────────────────────────┘
当 I/O 任务完成后,libuv 将回调放入相应的队列中。事件循环不断检查这些队列,按轮次执行回调——这正是“循环”的含义。
注意: 你的 JavaScript 代码始终在单个线程上运行。如果你写了很重的同步代码(比如大量计算循环),它将阻塞整个事件循环,导致所有其他请求都必须等待。
3. 事件循环的各个阶段
事件循环不是简单地运行一个循环,而是在每次迭代(tick)中按固定顺序经过 6 个阶段:
┌───────────────────────┐
┌─>│ timers │ setTimeout, setInterval
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ pending callbacks │ 被延迟的 I/O 回调
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ idle, prepare │
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ poll │ I/O 事件(读文件、网络)
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ check │ setImmediate
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
└──┤ close callbacks │ socket.on('close')
└─────────────────────────┘
3.1. 每个阶段的简短描述
- timers:执行已到期的
setTimeout/setInterval回调。 - pending callbacks:执行上一个循环周期中被延迟的 I/O 回调。
- poll:等待并处理新的 I/O 事件(文件读取完成、收到网络响应等)。这是耗时最多的阶段。
- check:执行
setImmediate的回调。 - close callbacks:处理关闭事件,比如
socket.on('close', ...)。
每个阶段都有自己的队列。事件循环处理完(或达到限制)当前阶段队列中的所有回调后,才会移动到下一个阶段。
4. Microtask 队列:Promise 和 process.nextTick
除了上面的 6 个 macrotask 阶段,Node.js 还有微任务队列,它在每个阶段之间被处理——不需要等待整个循环完成后才运行。
有两种类型的微任务:
process.nextTick()——独立的队列,始终在所有其他微任务之前被处理。Promise.then/catch/finally——标准的微任务队列(按 ECMAScript 规范)。
console.log('1 - sync start');
setTimeout(() => console.log('2 - setTimeout'), 0);
Promise.resolve().then(() => console.log('3 - promise'));
process.nextTick(() => console.log('4 - nextTick'));
console.log('5 - sync end');
解释:
- 同步
console.log立即运行(1、5)。 setTimeout将回调推送到 timers 队列——在下一个阶段运行。process.nextTick和Promise.then都是微任务,但nextTick有更高的优先级。- 同步代码运行完后,Node.js 先清空
nextTick队列,再清空Promise微任务队列,然后才进入 timers 阶段。
1 - sync start
5 - sync end
4 - nextTick
3 - promise
2 - setTimeout
注意: 如果你递归地不断调用
process.nextTick()(例如在一个回调中再次调用nextTick),事件循环将永远不会进入下一个阶段——这被称为“I/O 饥饿”。
5. setTimeout vs setImmediate:哪个顺序是对的?
这是最容易引起混淆的问题。在顶层调用时,setTimeout(fn, 0) 和 setImmediate(fn) 之间的顺序不固定,但在 I/O 回调中调用时是固定的。
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
因为这段代码在 fs.readFile 的回调中——即在 poll 阶段——所以当 poll 阶段结束时,事件循环总是先切换到 check 阶段(setImmediate),然后新循环才回到 timers 阶段。
immediate
timeout
相反,如果在顶层放置这两个命令(在任何 I/O 回调之外),顺序取决于当时 Node.js 启动进程的性能——不应该在生产代码中依赖这个顺序。
6. 阻塞事件循环:最常见的错误
因为只有一个线程处理 JavaScript,任何运行时间长的同步代码都会阻塞整个服务器。
const express = require('express');
const app = express();
app.get('/fibonacci', (req, res) => {
function fib(n) {
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
res.send(`Result: ${fib(40)}`); // 阻塞!
});
app.listen(3000);
当一个请求调用 /fibonacci 时,fib(40) 函数同步运行,耗时几秒钟。在这期间,所有其他请求(即使只是一个简单的 ping)都必须等待,因为事件循环被卡在一个任务上。
6.1. 解决方案:将繁重工作推送到 Worker Thread
const { Worker } = require('worker_threads');
app.get('/fibonacci', (req, res) => {
const worker = new Worker('./fib-worker.js', {
workerData: { n: 40 }
});
worker.on('message', (result) => res.send(`Result: ${result}`));
worker.on('error', (err) => res.status(500).send(err.message));
});
// fib-worker.js
const { workerData, parentPort } = require('worker_threads');
function fib(n) {
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
parentPort.postMessage(fib(workerData.n));
Worker 在单独的线程上运行,不占用主线程。主请求的事件循环仍然空闲,可以在 worker 计算期间处理其他请求。
何时不需要 Worker Thread: 对于 I/O 任务(读取数据库、调用 API、读取文件)——这些已经是异步的,不需要 worker。Worker Thread 只对 CPU 密集型任务有用(重型计算、加密、图像处理等)。
7. 使用 perf_hooks 测量事件循环延迟
要在生产环境中检测事件循环是否被阻塞,可以使用内置的 perf_hooks 模块。
const { monitorEventLoopDelay } = require('perf_hooks');
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
setInterval(() => {
console.log(`Event loop delay (mean): ${histogram.mean / 1e6} ms`);
histogram.reset();
}, 5000);
monitorEventLoopDelay 创建一个直方图,测量事件循环相对于理想计划的“延迟”时间。如果 mean 值异常增加(例如超过 100ms),这表示某处有代码在阻塞。
8. 编写测试以验证执行顺序
为了确认对微任务/宏任务顺序的正确理解,可以编写测试,将日志记录到数组中,然后断言顺序。
// order.js
function recordOrder(log) {
log.push('sync-start');
setTimeout(() => log.push('timeout'), 0);
Promise.resolve().then(() => log.push('promise'));
process.nextTick(() => log.push('next-tick'));
log.push('sync-end');
}
module.exports = { recordOrder };
// order.test.js
const { recordOrder } = require('./order');
test('执行顺序:sync -> nextTick -> promise -> timeout', (done) => {
const log = [];
recordOrder(log);
// 同步代码立即运行完成
expect(log).toEqual(['sync-start', 'sync-end']);
setImmediate(() => {
expect(log).toEqual([
'sync-start',
'sync-end',
'next-tick',
'promise',
'timeout',
]);
done();
});
});
这个测试使用 Jest 和 setImmediate 来等待所有微任务和 timers 阶段完成,然后断言 log 的最终顺序。这是验证对事件循环理解的准确方式,而不仅仅是用眼睛看日志。
npx jest order.test.js
PASS ./order.test.js
✓ 执行顺序:sync -> nextTick -> promise -> timeout (5 ms)
9. 常见错误
- 滥用递归
process.nextTick:导致 I/O 饥饿,服务器无法处理新的请求。 - 认为
setTimeout(fn, 0)立即运行:实际上它总是至少等待事件循环的一个 tick,并且在所有微任务之后运行。 - 在主线程上运行 CPU 密集型代码:例如解析巨大的 JSON、高成本的 bcrypt 加密、同步图像处理——所有这些都应该推送到 Worker Thread 或子进程。
- 假设顶层的
setTimeoutvssetImmediate的顺序:不可靠,避免编写依赖于它的逻辑。
何时需要小心: 如果你的应用有处理大数据的端点(导出 Excel、计算统计、调整图像大小),应该定期测量
monitorEventLoopDelay,以便早期发现问题,防止影响整个服务器。
10. 总结
事件循环是 Node.js 能以低资源处理大量连接的原因,但它也带来了限制:所有同步 JavaScript 代码都在单个线程上运行。清楚地理解 6 个阶段(timers、pending callbacks、poll、check、close)和微任务队列的位置(process.nextTick、Promise)可以帮助你准确预测执行顺序,避免难以调试的错误。
遇到 CPU 密集型任务时,不要犹豫使用 worker_threads 或分离到单独的进程——事件循环只有在你不让它喘不过气来时才强大。