在代码中如何高效利用Node.js事件循环
目录
1. 简介:为何要关注事件循环
Node.js 是基于 V8 引擎和 libuv 库实现的单线程异步 I/O 运行时。其背后的核心机制正是 事件循环(Event Loop)。通过事件循环,Node.js 执行 JavaScript 代码、处理定时器、完成 I/O 操作并调度回调,从而在单线程中实现高并发。
掌握事件循环的运行原理,对于写出高效、稳定的 Node.js 应用至关重要。常见的性能瓶颈往往源于:
- 不恰当使用计时器(
setTimeout
/setImmediate
)导致 I/O 被“饿死” - 误用
process.nextTick
或Promise.then
造成“微任务饥饿” - 长时间同步计算阻塞主线程,使后续任务延迟甚至应用无响应
- 并发过高时,未能合理控制并发量,导致系统资源枯竭
本文将从事件循环基本阶段入手,结合示例与图解,详细讲解哪些 API 在何时触发、它们的执行顺序,以及在实际代码中如何高效利用事件循环,避免常见陷阱与性能问题。
2. Node.js 事件循环概览
在深入示例之前,先了解 Node.js 事件循环的整体结构。Event Loop 的主要作用是不断地从不同的“阶段”中取出任务并执行,直到任务队列为空为止。
2.1 事件循环的六个主要阶段
Node.js (libuv) 的事件循环大致可分为以下六个阶段(Phases):
Timers 阶段
- 负责执行到期的
setTimeout
和setInterval
回调。
- 负责执行到期的
Pending Callbacks 阶段
- 执行一些系统操作回调,如 TCP 错误回调等。
Idle, Prepare 阶段
- 内部使用阶段,不直接暴露给用户。
Poll 阶段
- 处理 I/O 回调,如网络请求、文件读写完成后的回调。
- 如果此时没有到期的计时器且没有待处理 I/O 回调,会进入 check 阶段或阻塞在此等待新事件。
Check 阶段
- 执行
setImmediate
注册的回调。
- 执行
Close Callbacks 阶段
- 执行被
close
事件触发的回调,如socket.on('close')
。
- 执行被
┌────────────────────────────────────────────────────────┐
│ Event Loop │
├────────────────────────────────────────────────────────┤
│ 1. timers (执行过期 setTimeout/setInterval) │
│ 2. pending callbacks (TCP 错误回调等系统回调) │
│ 3. idle/prepare (内部使用) │
│ 4. poll (I/O 回调: fs 读写、网络请求等) │
│ 5. check (执行 setImmediate 回调) │
│ 6. close callbacks (执行 close 事件回调) │
└────────────────────────────────────────────────────────┘
以上各阶段会循环执行。当所有阶段都完成一次循环后,又会从第 1 阶段再次开始,下图简化了循环过程:
┌────────────────────────────────────────────────┐
│ timers (阶段 1) │
│ ┌────────────────────────────────────────────┐ │
│ │ pending callbacks (阶段 2) │ │
│ │ ┌────────────────────────────────────────┐ │ │
│ │ │ idle/prepare (阶段 3) │ │ │
│ │ │ ┌──────────────────────────────────┐ │ │ │
│ │ │ │ poll (阶段 4) │ │ │ │
│ │ │ │ ┌──────────────────────────────┐ │ │ │ │
│ │ │ │ │ check (阶段 5) │ │ │ │ │
│ │ │ │ │ ┌──────────────────────────┐ │ │ │ │ │
│ │ │ │ │ │ close callbacks (阶段 6) │ │ │ │ │ │
│ │ │ │ │ └──────────────────────────┘ │ │ │ │ │
│ │ │ │ └──────────────────────────────┘ │ │ │ │
│ │ │ └──────────────────────────────────┘ │ │ │
│ │ └────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────┘ │
└────────────────────────────────────────────────┘
注意:在 Node.js 世界中,微任务队列 (Microtasks Queue)——即 process.nextTick
和 Promise.then/catch/finally
注册的回调——不属于上述六大阶段之一。它们会在每次“阶段结束后”立即执行,甚至在同一个阶段内。如果无限制地生成微任务,会导致事件循环某个阶段无法推进,从而阻塞其他回调的执行,形成“微任务饥饿”。
2.2 宏任务与微任务 (Macrotasks vs. Microtasks)
宏任务 (Macrotasks)
- 包括:
setTimeout
、setInterval
、setImmediate
、I/O 回调、process.nextTick
并不算宏任务但与宏任务交互紧密。 - 通常由 libuv 在轮询(poll)过程中挑选。
- 包括:
微任务 (Microtasks)
- 包括:
process.nextTick
、Promise.then/catch/finally
。 - 在当前宏任务执行结束后、进入下一个宏任务前,立即把所有微任务队列中的回调执行完。
- 包括:
┌─────────────────────────────────────────────────┐
│ 执行某个宏任务(Task) │
│ ┌─────────────────────────────────────────────┐ │
│ │ 当前 宏任务 逻辑 运行时 │ │
│ │ 调用了 process.nextTick(fn) 或 promise.then │ │
│ │ 则 fn 被加入 微任务队列 │ │
│ └─────────────────────────────────────────────┘ │
│ 宏任务 结束后: │
│ ┌────────────────────────────────────────┐ │
│ │ 执行所有 微任务 队列中的回调 │ │
│ └────────────────────────────────────────┘ │
│ 然后进入下一个 宏任务(如下个 setImmediate) │
└─────────────────────────────────────────────────┘
- 如果不停地产生微任务(例如在微任务里又持续调用
process.nextTick
),会一直在这一步循环,导致事件循环无法推进到下一个阶段。 - 因此,使用微任务需要谨慎,避免“饥饿”或无限递归,尤其是在复杂业务场景下。
3. 核心 API:setTimeout
、setImmediate
、process.nextTick
、Promise
在实际编程中,掌握几个关键的异步调度 API 有助于精确控制回调执行时机,以免产生意料之外的顺序问题。
3.1 setTimeout(fn, 0)
与 setImmediate(fn)
的区别
setTimeout(fn, 0)
- 将函数
fn
放入计时器队列(timers 阶段),至少延迟约 1\~2ms(取决于系统定时器精度)。 - 属于宏任务的一种。
- 将函数
setImmediate(fn)
- 直接将函数
fn
放入check 阶段队列,待当前 poll 阶段结束后立即执行。 - 在 I/O 周期完成后(即 poll 阶段结束)执行,通常会比
setTimeout(fn, 0)
更早。 - 也是宏任务的一种,但所在阶段不同于 timers。
- 直接将函数
┌─────────────────────────────────────────┐
│ Event Loop │
├─────────────────────────────────────────┤
│ timers 阶段 (到期的 setTimeout/Interval) │
│ ↳ 执行 setTimeout(fn, 0) │
├─────────────────────────────────────────┤
│ pending callbacks │
├─────────────────────────────────────────┤
│ idle/prepare │
├─────────────────────────────────────────┤
│ poll (I/O 回调) │
│ ↳ 结束后立即进入 check 阶段 │
├─────────────────────────────────────────┤
│ check 阶段 │
│ ↳ 执行 setImmediate(fn) │
├─────────────────────────────────────────┤
│ close callbacks │
└─────────────────────────────────────────┘
因此,如果在一个 I/O 回调内部同时调用 setTimeout(fn, 0)
和 setImmediate(fn)
,通常会发现后者先执行:
fs.readFile('file.txt', () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// 预期输出:
// immediate
// timeout
3.2 process.nextTick(fn)
与 Promise.then(fn)
的区别
process.nextTick(fn)
- 将
fn
加入Node.js 自己的微任务队列,会在当前阶段执行完后、任何其他微任务之前(甚至在Promise
微任务之前)被执行。 - 具有极高优先级,若在每个回调中都反复调用,会导致事件循环“饥饿”,永远无法推进到后续阶段。
- 将
Promise.then(fn)
- 将
fn
加入标准微任务队列(Microtasks Queue),会在当前宏任务结束后执行,但低于process.nextTick
的优先级。 - 不会阻塞
process.nextTick
,但仍然会阻塞后续宏任务。
- 将
┌───────────────────────────────────────────────────────┐
│ 当前 正在执行某个回调(例如 I/O 回调或定时器回调) │
│ │
│ 调用了 process.nextTick(fn1),fn1 加入 nextTick 队列 │
│ 调用了 Promise.resolve().then(fn2),fn2 加入 Promise 微任务队列 │
│ │
│ 当前回调 结束后: │
│ 1. 执行 nextTick 队列(先执行 fn1) │
│ 2. 执行 Promise 微任务队列(再执行 fn2) │
│ 3. 进入下一个阶段(如 setImmediate / setTimeout) │
└───────────────────────────────────────────────────────┘
示例对比:
console.log('start');
process.nextTick(() => {
console.log('nextTick');
});
Promise.resolve().then(() => {
console.log('promise');
});
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
console.log('end');
// 预期输出顺序:
// start
// end
// nextTick
// promise
// immediate (因为 no I/O,timers 和 check 竞态,但在空闲情况下 setImmediate 会先于 setTimeout 执行)
// timeout
4. 示例:异步任务执行顺序解析
下面通过一个综合示例,演示在一个独立脚本中如何混合使用上述四种 API,并从输出顺序中理解事件循环的实际运行。
4.1 最简单的顺序:同步 → nextTick
→ Promise
→ setImmediate
→ setTimeout
假设我们写一个脚本 order.js
,内容如下:
// order.js
console.log('script start');
setTimeout(() => {
console.log('timeout 0');
}, 0);
setImmediate(() => {
console.log('immediate');
});
process.nextTick(() => {
console.log('nextTick 1');
});
Promise.resolve().then(() => {
console.log('promise 1');
});
process.nextTick(() => {
console.log('nextTick 2');
});
Promise.resolve().then(() => {
console.log('promise 2');
});
console.log('script end');
分析执行顺序:
同步代码执行
console.log('script start')
→ 输出script start
setTimeout(...)
注册一个 0ms 计时器,加入 timers 队列(下个循环)setImmediate(...)
注册加入 check 队列process.nextTick(...)
将回调加入 nextTick 队列Promise.resolve().then(...)
将回调加入 Promise 微任务队列process.nextTick(...)
再次加入 nextTick 队列Promise.resolve().then(...)
再次加入 Promise 微任务队列console.log('script end')
→ 输出script end
当前阶段结束后,执行微任务
nextTick 队列优先执行:
- 输出
nextTick 1
- 输出
nextTick 2
- 输出
Promise 微任务队列:
- 输出
promise 1
- 输出
promise 2
- 输出
进入 check 阶段
如果在此脚本中没有 I/O,libuv 会先进入 check 阶段后才进入 timers 阶段,所以:
- 执行
setImmediate
回调 → 输出immediate
- 执行
进入 timers 阶段
- 执行到期的
setTimeout(..., 0)
→ 输出timeout 0
- 执行到期的
最终输出顺序:
script start
script end
nextTick 1
nextTick 2
promise 1
promise 2
immediate
timeout 0
4.2 代码演示与图解
下面给出对应的 ASCII 流程图,帮助直观理解上面顺序:
┌─────────────────────────────────────────────────────────────────────────┐
│ 1. 同步执行 (主线) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ console.log('script start') → 输出 "script start" │ │
│ │ setTimeout(...) -> 加入 【timers】队列 │ │
│ │ setImmediate(...) -> 加入 【check】队列 │ │
│ │ process.nextTick(...) -> 加入 【nextTick】队列 │ │
│ │ Promise.resolve().then(...) -> 加入 【Promise 微任务】队列 │ │
│ │ process.nextTick(...) -> 再次加入 【nextTick】队列 │ │
│ │ Promise.resolve().then(...) -> 再次加入 【Promise 微任务】队列 │ │
│ │ console.log('script end') → 输出 "script end" │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────────────────────────┐
│ 2. 宏任务(当前回调) 完成后 │
│ 执行 微任务队列 (按优先级:nextTick -> Promise.then) │
│ │
│ 【nextTick 队列】: │
│ nextTick 1 → 输出 "nextTick 1" │
│ nextTick 2 → 输出 "nextTick 2" │
│ 【Promise 微任务队列】: │
│ promise 1 → 输出 "promise 1" │
│ promise 2 → 输出 "promise 2" │
│ │
└────────────────────────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────────────────────────┐
│ 3. 进入 check 阶段 (仅当无 I/O 时) │
│ 执行 setImmediate 回调 → 输出 "immediate" │
└────────────────────────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────────────────────────┐
│ 4. 进入 timers 阶段 │
│ 执行到期的 setTimeout 回调 → 输出 "timeout 0" │
└────────────────────────────────────────────────────────────────────────┘
5. 高效利用事件循环:最佳实践
了解了事件循环的基本模型后,下文将针对常见场景与误区,给出高效使用事件循环的实践建议。
5.1 避免阻塞主线程:长时间计算与 I/O 分离
Node.js 单线程模型意味着一旦主线程被占用(例如执行复杂的同步计算),整个事件循环会被阻塞,后续的 I/O 操作、定时器、回调都无法得到执行,导致应用无响应。
案例:阻塞主线程示例
// blocking.js
console.log('start blocking');
// 模拟长时间同步计算
function fib(n) {
if (n < 2) return n;
return fib(n - 1) + fib(n - 2);
}
const result = fib(40); // 可能耗时几百 ms
console.log('fib(40) =', result);
setTimeout(() => {
console.log('timer callback');
}, 0);
console.log('end blocking');
fib(40)
会阻塞主线程,在其执行期间,任何setTimeout
回调都不得不等待。执行顺序类似:
start blocking (花费 500ms 计算完 fib) fib(40) = 102334155 end blocking (此时才开始计时器阶段) timer callback
解决思路:
将长计算放到子进程或 Worker Threads
Node.js v10+ 提供 Worker Threads,可将 CPU 密集型任务放到子线程,主线程继续服务 I/O。// worker.js const { parentPort, workerData } = require('worker_threads'); function fib(n) { /* 同上 */ } const result = fib(workerData.n); parentPort.postMessage(result);
// main.js const { Worker } = require('worker_threads'); console.log('start non-blocking'); const worker = new Worker('./worker.js', { workerData: { n: 40 } }); worker.on('message', (result) => { console.log('fib(40) =', result); }); worker.on('error', (err) => console.error(err)); setTimeout(() => { console.log('timer callback'); }, 0); console.log('end non-blocking');
输出顺序:
start non-blocking end non-blocking timer callback fib(40) = 102334155
对于 I/O 密集型操作,尽量使用异步 API
- 使用
fs.readFile
而非fs.readFileSync
- 使用
child_process.spawn
而非execSync
- 结合流(Streams)处理大文件,避免一次载入内存
- 使用
5.2 合理使用微任务回调与批量操作
避免在微任务中递归调用
process.nextTick
或Promise.then
这会导致微任务队列永远无法清空,从而饿死整个宏任务队列。// 饥饿示例:永远不会执行 setImmediate 和 setTimeout function eatMicrotasks() { process.nextTick(() => { console.log('tick'); eatMicrotasks(); }); } eatMicrotasks(); setImmediate(() => console.log('immediate')); // 永远不会打印 setTimeout(() => console.log('timeout'), 0); // 永远不会打印
在大量数据处理时,划分异步批次
假设有一个庞大的数组,需要对每个元素进行异步操作。直接使用Promise.all
可能一次并发过高,导致内存或 I/O 资源枯竭。应分批次处理,并在每个批次之间插入微任务或宏任务边界,让事件循环得以喘息。async function processInBatches(items, batchSize = 100) { for (let i = 0; i < items.length; i += batchSize) { const batch = items.slice(i, i + batchSize); await Promise.all(batch.map(async (item) => { // 异步处理 })); // 可选:插入一个微任务空闲,让事件循环先跑完微任务再继续 await Promise.resolve(); // 等同于一个微任务边界 // 或者使用 setImmediate(() => {}) 插入一个宏任务边界 } }
5.3 掌握定时器与 I/O 之间的权衡
setImmediate
优先于setTimeout(fn, 0)
- 当处于 I/O 回调内部时,使用
setImmediate
可以让回调更早得到执行。 - 如果想让某段回调在当前 I/O 周期尽快运行,首选
setImmediate
。
- 当处于 I/O 回调内部时,使用
在服务器高并发场景下避免过多高频定时器
- 例如,避免在高并发情况下使用大量短间隔
setInterval(fn, 1)
,容易导致事件循环过度紧张。
- 例如,避免在高并发情况下使用大量短间隔
使用
timersPromises
Node.js v15+ 提供timers/promises
模块,允许在async/await
中使用定时器,更直观地按顺序书写延时逻辑:import { setTimeout } from 'timers/promises'; async function delayedTask() { console.log('start'); await setTimeout(1000); // 等待 1 秒 console.log('after 1s'); } delayedTask();
5.4 结合异步资源池与节流/防抖
异步资源池 (Connection Pool, Task Queue Pool)
在高并发请求外部资源(数据库、HTTP API)时,使用资源池控制并发数量,防止 I/O 饱和或服务端拒绝。常见做法:import pLimit from 'p-limit'; const limit = pLimit(10); // 最多 10 个并发 const tasks = urls.map((url) => { return limit(async () => { const res = await fetch(url); return res.text(); }); }); const results = await Promise.all(tasks);
节流 (Throttle) 与 防抖 (Debounce)
对频繁触发的事件(如 WebSocket 消息、用户操作)做限流,有助于减轻事件循环压力。例如,当接收大量消息时,只在一定时间窗内处理一次。function throttle(fn, wait) { let lastTime = 0; return function (...args) { const now = Date.now(); if (now - lastTime >= wait) { lastTime = now; fn.apply(this, args); } }; } const handleMessage = throttle((msg) => { console.log('处理消息:', msg); }, 100); // 最多每 100ms 处理一次
6. 实战:构建高并发 HTTP 请求示例
下面通过一个示例,展示如何在事件循环模型中高效并发地发起大量 HTTP 请求,并对比不同方案的表现。
6.1 使用 Promise.all
与批量控制
假设我们要对 1000 个 URL 发起 GET 请求,并收集结果。最简单的做法:
import axios from 'axios';
async function fetchAll(urls) {
const promises = urls.map((url) => axios.get(url));
const results = await Promise.all(promises);
return results.map(res => res.data);
}
问题:当 urls.length
很大时,一次性并发发起过多请求,会导致:
- 消耗大量文件描述符:Linux 默认限制同时打开的文件数,如果超过,会报
EMFILE
错误。 - I/O 饥饿:事件循环过度消费在网络 I/O,而 CPU 微任务、其他回调得不到执行。
6.2 利用 setImmediate
避免 I/O 饥饿
我们可以分批发起请求,在每批结束后插入一个宏任务空闲,让事件循环有机会处理其他 I/O 事件或微任务。
import axios from 'axios';
async function fetchAllInBatches(urls, batchSize = 50) {
let results = [];
for (let i = 0; i < urls.length; i += batchSize) {
const batch = urls.slice(i, i + batchSize);
// 并发发起当前批次的请求
const resBatch = await Promise.all(batch.map(url => axios.get(url)));
results = results.concat(resBatch.map(res => res.data));
// 插入一个宏任务空闲
await new Promise(resolve => setImmediate(resolve));
}
return results;
}
// 使用示例
(async () => {
const urls = [/* 1000 个 URL */];
const data = await fetchAllInBatches(urls, 100);
console.log('所有请求完成');
})();
这里的关键是:每 100 个请求完成后,通过 new Promise(resolve => setImmediate(resolve))
创造一个 setImmediate 宏任务,让事件循环先去处理其他 I/O 回调、定时器或微任务,防止单一任务过度占用。
6.3 示例代码与性能对比
下面同时运行 Promise.all
(一次性并发)与 fetchAllInBatches
(分批+setImmediate)两种方案,观察对大量请求场景下的影响。为了演示可扩展性,这里使用一个模拟慢响应的本地 HTTP 服务器。
模拟慢响应服务器(
slowServer.js
)// slowServer.js import express from 'express'; const app = express(); app.get('/delay/:ms', (req, res) => { const ms = parseInt(req.params.ms); setTimeout(() => { res.json({ delay: ms }); }, ms); }); app.listen(5000, () => { console.log('慢响应服务器已启动,监听端口 5000'); });
启动:
node slowServer.js
每次访问
http://localhost:5000/delay/100
,会在 100ms 后返回{ delay: 100 }
。一次性并发方案(
allAtOnce.js
)// allAtOnce.js import axios from 'axios'; async function fetchAll(urls) { const promises = urls.map(url => axios.get(url)); const results = await Promise.all(promises); return results.map(res => res.data); } (async () => { const urls = Array.from({ length: 200 }, (_, i) => `http://localhost:5000/delay/100`); console.time('all-at-once'); const data = await fetchAll(urls); console.timeEnd('all-at-once'); console.log('结果条数:', data.length); })();
分批+
setImmediate
方案(batchImmediate.js
)// batchImmediate.js import axios from 'axios'; async function fetchAllInBatches(urls, batchSize = 50) { let results = []; for (let i = 0; i < urls.length; i += batchSize) { const batch = urls.slice(i, i + batchSize); const resBatch = await Promise.all(batch.map(url => axios.get(url))); results = results.concat(resBatch.map(res => res.data)); // 插入宏任务空闲 await new Promise(resolve => setImmediate(resolve)); } return results; } (async () => { const urls = Array.from({ length: 200 }, (_, i) => `http://localhost:5000/delay/100`); console.time('batch-immediate'); const data = await fetchAllInBatches(urls, 50); console.timeEnd('batch-immediate'); console.log('结果条数:', data.length); })();
对比执行
- 确保
slowServer.js
已启动。 在不同终端分别执行:
node allAtOnce.js node batchImmediate.js
你会发现:
all-at-once
大约耗时略低(并行度高,但瞬时并发 200 个请求会让系统发起过多 I/O 任务)。batch-immediate
会稍微耗时更长,但对系统资源更友好,且事件循环更平稳,不容易出现 I/O 饥饿或超时错误。
- 确保
通过这种分批+setImmediate
的手段,可让事件循环在高并发下依然保持健康,避免主线程被大量网络 I/O 回调塞满,从而导致其他重要回调(如定时器、微任务)无法及时执行。
7. 总结
本文从Node.js 事件循环的六大阶段、宏任务与微任务的区别、核心调度 API(setTimeout
、setImmediate
、process.nextTick
、Promise
)等方面,结合ASCII 图解与代码示例,详细剖析了事件循环的执行顺序和常见误区。
关键要点回顾:
- 事件循环阶段:按顺序执行 timers → pending callbacks → idle/prepare → poll → check → close callbacks。
- 微任务优先级:
process.nextTick
比Promise.then
优先级更高;微任务会在宏任务结束后立即执行,可能导致“微任务饥饿”。 setImmediate
vssetTimeout(fn, 0)
:在 I/O 回调内部,setImmediate
通常会先于setTimeout(fn, 0)
执行。- 避免主线程阻塞:长时间计算或同步 I/O 会阻塞后续回调,应使用 Worker Threads、子进程或异步 API 予以拆分。
- 分批与宏任务空闲:对于大量并发 I/O,可分批处理并在批次间插入
setImmediate
宏任务,让事件循环有机会处理其他任务。 - 合理使用微任务:微任务易于写出连续逻辑,但切勿在微任务里无限调用
process.nextTick
或Promise.then
。
掌握以上原则,能帮助你在日常开发中:
- 针对延迟与吞吐进行权衡,避免 I/O 饥饿
- 在高并发场景下保持事件循环平稳,提高系统可用性
- 合理安排回调顺序,确保时序敏感的逻辑按预期运行
建议作为后续学习方向:
- 深入研究 libuv 的底层实现,了解不同平台(Linux、Windows、macOS)对 I/O 模型的影响
- 探索 Worker Threads 与 Cluster 模式,提升计算密集与多核利用能力
- 结合性能分析工具(如
clinic.js
、node --prof
),找出事件循环瓶颈并优化
希望本文能帮助你在实践中更高效地利用 Node.js 事件循环,写出既高性能又稳定可靠的异步代码。
评论已关闭