Node.js 中的**事件循环(Event Loop)**是其异步编程的核心机制。
它是 Node.js 在运行时用于处理非阻塞操作(比如 I/O、定时器、Promise、事件监听等)的机制,依赖于底层的 libuv 库来实现跨平台的事件驱动模型。
1.单线程机制
我们先利用 koa 框架来介绍下 Nodejs 中存在的线程阻塞问题。
1-1.同步阻塞
readFileSync 是同步阻塞的,它会阻塞主线程,导致无法处理其他请求。
const fs = require('fs')
app.use(ctx => {
const a = fs.readFileSync('a.txt', 'utf8')
const b = fs.readFileSync('b.txt', 'utf8')
const c = fs.readFileSync('c.txt', 'utf8')
ctx.body = a + b + c
})
app.listen(3000)TIP
当一个请求进来时,
Node.js会在主线程里一步一步执行readFileSync;所有文件读完前,主线程无法处理任何新请求;
并发请求会排队,性能严重下降。
1-2.异步非阻塞
readFile 是异步非阻塞的,它会立即返回,不会阻塞主线程,可以处理其他请求。
const Koa = require('koa')
const fs = require('fs/promises') // 使用 Promise 版本的 fs
const app = new Koa()
app.use(async ctx => {
const a = await fs.readFile('a.txt', 'utf8')
const b = await fs.readFile('b.txt', 'utf8')
const c = await fs.readFile('c.txt', 'utf8')
ctx.body = a + b + c
})
app.listen(3000)第一步:客户端发起请求
Node.js主线程通过事件循环监听端口3000;有一个请求来了(例如
curl http://localhost:3000/);事件循环将这个请求的回调交给
Koa处理(app.use(...)函数被执行)。
第二步:await fs.readFile('a.txt')
fs.readFile是异步操作,它不会阻塞主线程;Node.js会把读取a.txt的任务交给libuv的线程池去处理;主线程此时挂起这个
await,然后继续事件循环,处理其他请求;
如果此时有第二个客户端请求来了,它不会被“
a.txt还没读完”卡住,而是会立刻被处理。
第三步:a.txt 读取完成,事件循环继续
当线程池读取完
a.txt后,告诉事件循环:“回调可以执行了”;事件循环安排这个异步回调进入“微任务队列”或下一轮的事件阶段中;
接着执行
await fs.readFile('b.txt'),这个过程再次是异步的,线程池继续工作;重复上述过程直到
a、b、c都读完。
第四步:所有文件读取完毕,发送响应
Koa收到所有结果后,将结果写入ctx.body;Koa内部使用res.end()将响应发送回客户端;这一整套流程完成,事件循环等待下一次事件。
2. 事件循环的阶段
Node.js 的事件循环大致分为以下几个阶段(每个阶段是一个队列):
┌───────────────────────┐
│ timers │ ← setTimeout/setInterval 回调
├───────────────────────┤
│ pending callbacks │
├───────────────────────┤
│ idle, prepare │
├───────────────────────┤
│ poll │ ← 执行 I/O 回调(如 fs.readFile 回调)
├───────────────────────┤
│ check │ ← setImmediate 回调
├───────────────────────┤
│ close callbacks │
└───────────────────────┘2-1.timers
执行 setTimeout() 和 setInterval() 的回调。
2-2.pending callbacks
执行一些系统操作的回调(如 TCP 错误类型的回调)。
2-3.idle, prepare
内部使用,用户代码不会直接接触。
2-4.poll
等待 I/O 事件、处理 I/O 回调(例如:读取文件、网络请求等)。
2-5.check
执行 setImmediate() 的回调。
2-6.close callbacks
如 socket.on('close', ...)。
每次事件循环周期(
tick)都会从头到尾执行以上这些阶段。
3. microtask(微任务)队列
在每个阶段之间,Node.js 会在主任务完成后立即清空所有的“微任务”(microtasks)队列:
包括:
process.nextTick()(这是Node.js特有的,比Promise微任务还要快)Promise.then/catch/finally
执行顺序是:
当前阶段任务 →
process.nextTick()队列 →Promise微任务队列 →下一个阶段
TIP
那么,结合阶段和微任务,我们就可以得到一个完整的 Node.js 事件循环的执行顺序:
process.nextTick() > Promise 微任务 > timers(setTimeout) > setImmediate尽量不要在
nextTick或Promise.then中嵌套太深,会阻塞I/O阶段,导致“饿死I/O”;setImmediate更适合处理“下一轮事件循环”,而非立即执行;setTimeout(fn, 0)实际延迟时间不一定为0,取决于系统和Node的处理。
4. 测试代码
以上的结论,跟 node 版本存在一定联系。
我们此节的测试代码是基于 node@22.14.0 版本。
4-1.
setTimeout(() => {
console.log('setTimeout')
}, 0)
setImmediate(() => {
console.log('setImmediate')
})
Promise.resolve().then(() => {
console.log('Promise')
})
process.nextTick(() => {
console.log('nextTick')
})打印结果:
nextTick
Promise
setTimeout
setImmediate4-2.
const fs = require('fs');
setTimeout(() => console.log('timeout1'), 0);
setImmediate(() => console.log('immediate1'));
fs.readFile('event-loop.js', () => {
console.log('IO')
setTimeout(() => console.log('timeout2'), 0);
setImmediate(() => console.log('immediate2'));
});打印结果:
timeout1
IO
immediate1
immediate2
timeout2