什么是事件循环
事件循环(Event Loop) 是 Node.js 的核心机制,它使得 Node.js 能够在单线程环境下高效地处理大量并发操作。尽管 JavaScript 是单线程的,但通过事件循环和非阻塞 I/O,Node.js 可以同时处理成千上万的连接。
为什么需要事件循环?
在传统的多线程服务器模型中,每个请求都会创建一个新线程,这会导致:
- 资源消耗大:每个线程都需要内存和 CPU 资源
- 上下文切换开销:线程间的切换会消耗 CPU 时间
- 并发限制:受限于系统能创建的线程数量
Node.js 通过事件循环解决了这些问题:
- 单线程执行:JavaScript 代码在单一主线程中运行
- 非阻塞 I/O:I/O 操作在后台线程池中执行,不阻塞主线程
- 事件驱动:通过回调函数处理异步操作的结果
事件循环的工作原理
事件循环是一个持续运行的循环,它不断地检查是否有待处理的任务,并按照特定的顺序执行它们。
基本执行流程
sql
┌───────────────────────────┐
│ Node.js 启动 │
└───────────┬───────────────┘
│
▼
┌───────────────────────────┐
│ 执行同步代码 │
│ (主脚本) │
└───────────┬───────────────┘
│
▼
┌───────────────────────────┐
│ 进入事件循环 │
│ ┌─────────────────────┐ │
│ │ 1. Timers │ │
│ │ 2. Pending Callbacks│ │
│ │ 3. Idle, Prepare │ │
│ │ 4. Poll │ │
│ │ 5. Check │ │
│ │ 6. Close Callbacks │ │
│ └─────────────────────┘ │
└───────────┬───────────────┘
│
▼
┌───────────────────────────┐
│ 检查是否有待处理任务 │
└───────────┬───────────────┘
│
┌─────┴─────┐
│ │
是 否
│ │
▼ ▼
继续循环 退出程序
关键概念
- 调用栈(Call Stack):执行同步代码的地方
- 任务队列(Task Queue):存储待执行的异步回调
- 微任务队列(Microtask Queue) :存储 Promise 回调(
Promise.then/catch/finally) - nextTick 队列(NextTick Queue) :存储
process.nextTick回调(独立于微任务队列,优先级更高) - 事件循环:协调调用栈和任务队列的机制
事件循环的六个阶段
根据 Node.js 官方文档,事件循环包含以下六个阶段:
1. Timers(定时器阶段)
执行由 setTimeout() 和 setInterval() 设置的回调函数。
javascript
setTimeout(() => {
console.log('Timer 1');
}, 0);
setInterval(() => {
console.log('Interval');
}, 1000);
特点:
- 只执行已经到期的定时器回调
- 定时器的延迟时间是最小延迟,不是精确延迟
- 如果事件循环被阻塞,定时器可能会延迟执行
2. Pending Callbacks(待处理的回调阶段)
执行延迟到下一个循环迭代的 I/O 回调。这些回调通常来自系统操作,主要是某些系统级别的错误回调(如 TCP 连接错误)。
javascript
const net = require('net');
// TCP 连接错误回调可能在这个阶段执行
const socket = net.createConnection(80, 'invalid-host');
socket.on('error', (err) => {
// 某些系统错误回调(如 ECONNREFUSED)会在这个阶段执行
console.error('Connection error:', err);
});
注意 :大部分 I/O 回调(包括 fs.readFile 的成功和错误回调)都在 Poll 阶段执行,而不是这个阶段。Pending Callbacks 阶段主要处理系统级别的错误回调。
3. Idle, Prepare(空闲、准备阶段)
仅供 Node.js 内部使用,通常不涉及用户代码。
4. Poll(轮询阶段)
这是事件循环的核心阶段,负责:
- 检索新的 I/O 事件
- 执行与 I/O 相关的回调(除了关闭回调、定时器回调和 setImmediate 回调)
javascript
const fs = require('fs');
fs.readFile('data.txt', 'utf8', (err, data) => {
// 这个回调在 Poll 阶段执行
console.log('File content:', data);
});
Poll 阶段的工作机制:
- 如果 Poll 队列不为空,同步执行队列中的回调,直到队列为空或达到系统限制
- 如果 Poll 队列为空:
- 如果有
setImmediate()回调,进入 Check 阶段 - 如果没有,等待新的 I/O 事件到达
- 如果有
5. Check(检查阶段)
执行由 setImmediate() 设置的回调函数。
javascript
setImmediate(() => {
console.log('setImmediate callback');
});
setImmediate vs setTimeout:
setImmediate()在当前事件循环的 Check 阶段执行setTimeout(fn, 0)在下一个事件循环的 Timers 阶段执行- 在 I/O 回调中,
setImmediate总是先于setTimeout执行
6. Close Callbacks(关闭回调阶段)
执行关闭事件的回调,如 socket.on('close', ...)。
javascript
const server = require('http').createServer();
server.on('close', () => {
console.log('Server closed');
});
server.close();
事件队列
事件队列是事件循环的核心数据结构,用于存储待执行的异步回调。事件循环通过管理 nextTick 队列、微任务队列和宏任务队列来协调异步操作的执行顺序。详细的队列类型和执行顺序说明请参考下面的"宏任务与微任务"章节。
宏任务与微任务
理解宏任务和微任务的区别对于掌握事件循环至关重要。
宏任务(Macrotasks)
宏任务包括:
setTimeout()setInterval()setImmediate()- I/O 操作回调
特点:
- 在每个事件循环阶段之间,会先清空 nextTick 队列,然后清空微任务队列
- 执行完所有微任务后,才进入下一个事件循环阶段
微任务(Microtasks)
微任务包括:
Promise.then()/Promise.catch()/Promise.finally()queueMicrotask()
特点:
- 优先级高于宏任务
- 在每个事件循环阶段结束后执行
- 会阻塞后续阶段的执行,直到微任务队列清空
注意 :process.nextTick() 虽然行为类似微任务,但它有自己独立的队列,优先级甚至高于 Promise 微任务。在每个阶段结束后,会先执行所有 process.nextTick 回调,然后才执行 Promise 等微任务。
执行示例
javascript
console.log('开始');
// 宏任务
setTimeout(() => {
console.log('setTimeout 1');
// 微任务(在宏任务内部)
Promise.resolve().then(() => {
console.log('Promise 2');
});
process.nextTick(() => {
console.log('nextTick 2');
});
}, 0);
// 微任务
Promise.resolve().then(() => {
console.log('Promise 1');
});
process.nextTick(() => {
console.log('nextTick 1');
});
// 宏任务
setTimeout(() => {
console.log('setTimeout 2');
}, 0);
console.log('结束');
// 输出顺序:
// 开始
// 结束
// nextTick 1
// Promise 1
// setTimeout 1
// nextTick 2
// Promise 2
// setTimeout 2
注意 :process.nextTick 的优先级甚至高于 Promise,过度使用可能导致事件循环阻塞。详细说明请参考下面的"process.nextTick 与 setImmediate"章节。
process.nextTick 与 setImmediate
这两个 API 经常被混淆,但它们有本质区别:
process.nextTick
- 执行时机:在当前操作完成后、进入下一个事件循环阶段之前立即执行(不是事件循环的一部分)
- 优先级:最高,甚至高于 Promise 微任务
- 用途:确保回调在当前操作完成后立即执行,用于保证 API 的异步性
javascript
console.log('开始');
setImmediate(() => {
console.log('setImmediate');
});
process.nextTick(() => {
console.log('nextTick');
});
console.log('结束');
// 输出:
// 开始
// 结束
// nextTick
// setImmediate
setImmediate
- 执行时机:在当前事件循环的 Check 阶段执行
- 优先级 :低于
process.nextTick和 Promise - 用途:在当前事件循环结束后执行回调
何时使用哪个?
javascript
// 使用 process.nextTick:需要立即执行,不阻塞 I/O
function asyncOperation(data, callback) {
if (data) {
process.nextTick(() => {
callback(null, data);
});
} else {
process.nextTick(() => {
callback(new Error('No data'));
});
}
}
// 使用 setImmediate:在 I/O 操作后执行
const fs = require('fs');
fs.readFile('file.txt', () => {
setImmediate(() => {
console.log('在 I/O 回调后执行');
});
setTimeout(() => {
console.log('在下一个事件循环执行');
}, 0);
});
事件循环优化策略
优化事件循环是提升 Node.js 应用性能的关键。
1. 避免阻塞主线程
问题代码:
javascript
// ❌ 阻塞事件循环
// 假设已有 Express 应用:const app = require('express')();
function heavyComputation() {
let result = 0;
for (let i = 0; i < 10000000000; i++) {
result += i;
}
return result;
}
app.get('/compute', (req, res) => {
const result = heavyComputation(); // 阻塞所有请求
res.json({ result });
});
优化方案:
javascript
// ✅ 使用 Worker Threads
const { Worker } = require('worker_threads');
const { join } = require('path');
function heavyComputationAsync() {
return new Promise((resolve, reject) => {
// 使用独立的 worker 文件(推荐方式)
const worker = new Worker(join(__dirname, 'worker.js'));
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker stopped with exit code ${code}`));
}
});
});
}
app.get('/compute', async (req, res) => {
const result = await heavyComputationAsync();
res.json({ result });
});
worker.js:
javascript
const { parentPort } = require('worker_threads');
let result = 0;
for (let i = 0; i < 10000000000; i++) {
result += i;
}
parentPort.postMessage(result);
2. 合理使用异步 API
问题代码:
javascript
// ❌ 同步 I/O 阻塞事件循环
// 假设已有 Express 应用:const app = require('express')();
const fs = require('fs');
app.get('/file', (req, res) => {
const data = fs.readFileSync('large-file.txt'); // 阻塞
res.send(data);
});
优化方案:
javascript
// ✅ 使用异步 I/O
const fs = require('fs').promises;
app.get('/file', async (req, res) => {
const data = await fs.readFile('large-file.txt');
res.send(data);
});
3. 控制微任务数量
问题代码:
javascript
// ❌ 创建大量微任务
function recursivePromise(count) {
if (count <= 0) return Promise.resolve();
return Promise.resolve().then(() => {
return recursivePromise(count - 1); // 可能阻塞事件循环
});
}
优化方案:
javascript
// ✅ 使用 setImmediate 拆分任务
function recursivePromise(count) {
if (count <= 0) return Promise.resolve();
return new Promise((resolve) => {
setImmediate(() => {
recursivePromise(count - 1).then(resolve);
});
});
}
4. 避免过度使用 process.nextTick
问题代码:
javascript
// ❌ 递归调用 process.nextTick
function recursiveNextTick(count) {
if (count <= 0) return;
process.nextTick(() => {
recursiveNextTick(count - 1); // 可能导致事件循环阻塞
});
}
优化方案:
javascript
// ✅ 使用 setImmediate 或拆分任务
function recursiveNextTick(count) {
if (count <= 0) return;
setImmediate(() => {
recursiveNextTick(count - 1);
});
}
5. 使用流处理大文件
问题代码:
javascript
// ❌ 一次性读取大文件到内存
// 假设已有 Express 应用:const app = require('express')();
const fs = require('fs');
app.get('/large-file', (req, res) => {
const data = fs.readFileSync('huge-file.txt'); // 可能耗尽内存
res.send(data);
});
优化方案:
javascript
// ✅ 使用流处理
const fs = require('fs');
app.get('/large-file', (req, res) => {
const stream = fs.createReadStream('huge-file.txt');
stream.pipe(res); // 流式传输,不占用大量内存
});
6. 监控事件循环延迟
javascript
// 监控事件循环延迟
const { performance, PerformanceObserver } = require('perf_hooks');
const obs = new PerformanceObserver((items) => {
const entry = items.getEntries()[0];
console.log(`Event Loop Delay: ${entry.duration}ms`);
// 如果延迟超过阈值,发出警告
if (entry.duration > 100) {
console.warn('Event loop is blocked!');
}
});
obs.observe({ entryTypes: ['measure'] });
// 初始化 start mark
performance.mark('start');
// 定期测量事件循环延迟
setInterval(() => {
setImmediate(() => {
performance.mark('end');
performance.measure('event-loop-delay', 'start', 'end');
performance.mark('start'); // 为下一次测量做准备
});
}, 1000);
7. 使用集群模式处理 CPU 密集型任务
javascript
const cluster = require('cluster');
const numCPUs = require('os').cpus().length;
// Node.js 16+ 使用 cluster.isPrimary,旧版本使用 cluster.isMaster
if (cluster.isPrimary) {
// 主进程:创建工作进程
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker) => {
console.log(`Worker ${worker.process.pid} died`);
cluster.fork(); // 重启工作进程
});
} else {
// 工作进程:运行应用
require('./app');
}
实际案例分析
案例 1:I/O 回调中的执行顺序
javascript
const fs = require('fs');
fs.readFile(__filename, () => {
console.log('1. I/O 回调');
setTimeout(() => {
console.log('2. setTimeout');
}, 0);
setImmediate(() => {
console.log('3. setImmediate');
});
Promise.resolve().then(() => {
console.log('4. Promise');
});
process.nextTick(() => {
console.log('5. nextTick');
});
});
console.log('6. 同步代码');
// 输出顺序:
// 6. 同步代码
// 1. I/O 回调
// 5. nextTick
// 4. Promise
// 3. setImmediate
// 2. setTimeout
注意 :在 I/O 回调中,setImmediate 总是先于 setTimeout 执行。这是因为 I/O 回调在 Poll 阶段执行,执行完毕后会进入 Check 阶段(执行 setImmediate),然后才进入下一个循环的 Timers 阶段(执行 setTimeout)。
总结
核心要点
-
事件循环是 Node.js 的核心:它使得单线程能够高效处理并发操作
-
六个阶段的执行顺序:
- Timers → Pending Callbacks → Idle/Prepare → Poll → Check → Close Callbacks
-
任务优先级(从高到低):
- nextTick 队列 (独立队列,不是事件循环的一部分):
process.nextTick()- 优先级最高,在当前操作完成后、进入下一个事件循环阶段之前立即执行
- 微任务(Microtasks) : 2.
Promise.then()/Promise.catch()/Promise.finally()- Promise 回调 3.queueMicrotask()- 标准微任务 API - 宏任务(Macrotasks) : 4.
setImmediate()- 在当前事件循环的 Check 阶段执行 5.setTimeout()/setInterval()- 在 Timers 阶段执行 6. I/O 操作回调(文件系统、网络操作等)- 在 Poll 阶段执行 7. Close Callbacks(关闭回调,如socket.on('close'))- 在 Close Callbacks 阶段执行
- nextTick 队列 (独立队列,不是事件循环的一部分):
-
优化原则:
- 永远不要阻塞事件循环
- 优先使用异步 API
- 合理控制微任务数量
- 使用流处理大文件
- 监控事件循环延迟
最佳实践
- ✅ 使用异步 I/O:避免同步文件操作和网络请求
- ✅ 拆分大任务:将 CPU 密集型任务拆分为小块
- ✅ 使用 Worker Threads:处理 CPU 密集型任务
- ✅ 使用流:处理大文件和数据流
- ✅ 监控性能:定期检查事件循环延迟
- ❌ 避免阻塞操作:不要在回调中执行耗时计算
- ❌ 避免过度使用 process.nextTick:可能导致事件循环阻塞
- ❌ 避免同步 API:除非绝对必要