第3章 3.4 事件循环 Event Loop 原理

🎯 开场:为什么你写的"异步"代码不按顺序执行?

上一章我们学会了 async/await 这个语法糖,终于能写出看起来像同步的异步代码了。但是,你有没有遇到过这种诡异的情况:

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');

你猜打印顺序是什么?答案是 1 → 4 → 3 → 2

等等,setTimeout(..., 0) 不是应该立即执行吗?为什么反而比 Promise 后面的 .then() 慢?

这就是事件循环(Event Loop)在搞鬼。你写的代码不是按照"从上到下"的顺序执行的,而是被事件循环"调度"着执行的。

学完这一章,你将彻底搞明白:
- 事件循环是怎么"排队"执行代码的
- 为什么 Promise 比 setTimeout 更快被执行
- process.nextTicksetImmediate 到底有什么区别
- 如何利用这些特性写出更高性能的代码


🧱 基础:事件循环是个什么"环"?

3.4.1 生活类比:餐厅的叫号系统

想象你去一家网红餐厅吃饭:

  1. 进门取号:你取了一个号,但前面还有20桌
  2. 等待叫号:服务员按顺序叫号(宏任务队列)
  3. 加急通道:VIP 可以走快速通道(微任务队列)
  4. 处理完再叫下一位:每一桌吃完,才叫下一桌

事件循环就是这个"叫号系统"。它负责决定:
- 谁先吃(哪个任务先执行)
- 怎么排队(宏任务还是微任务)
- VIP 插队规则(微任务可以打断宏任务)

配图1 - 配图1

3.4.2 事件循环的 6 个阶段(Phase)

Node.js 的事件循环有 6 个阶段,按顺序执行:

───────────────────────────┐
     1. timers (计时器)      │  ← setTimeout, setInterval
──────────┬────────────────┘
          ↓
───────────────────────────┐
  2. pending callbacks      │  ← 上一次循环延后的I/O回调
──────────┬────────────────┘
          ↓
───────────────────────────┐
  3. idle, prepare          │  ← 内部使用
──────────┬────────────────┘
          ↓
───────────────────────────┐
  4. poll (轮询)            │  ← 获取新的I/O事件
──────────┬────────────────┘
          ↓
───────────────────────────┐
  5. check (检查)           │  ← setImmediate
──────────┬────────────────┘
          ↓
───────────────────────────┐
  6. close callbacks       │  ← 关闭的回调(如socket.close())
──────────┬────────────────┘
          ↓
          ↓ ← 回到 timers 阶段

重点记住
- timers 阶段:执行 setTimeoutsetInterval 的回调
- check 阶段:执行 setImmediate 的回调
- 微任务(Promise、queueMicrotask)在每个阶段之间执行

3.4.3 宏任务 vs 微任务:谁更快?

类型 示例 执行时机
宏任务 (MacroTask) setTimeout, setInterval, I/O, setImmediate 当前阶段结束后,下一阶段前
微任务 (MicroTask) Promise.then, queueMicrotask, process.nextTick 当前阶段的所有任务执行完后,立即执行

记住这个顺序:微任务 > 宏任务

console.log('1');

// 宏任务 - 0秒后执行
setTimeout(() => console.log('2'), 0);

// 微任务 - 立即执行(比宏任务快)
Promise.resolve().then(() => console.log('3'));

console.log('4');

// 输出顺序:1 → 4 → 3 → 2

执行过程:
1. 执行同步代码 → 输出 14
2. 检查微任务队列 → 有 Promise.then → 输出 3
3. 检查宏任务队列 → 有 setTimeout → 输出 2

3.4.4 process.nextTick 和 queueMicrotask

这两个都是"插队"工具,但有点区别:

console.log('1');

process.nextTick(() => console.log('nextTick'));

queueMicrotask(() => console.log('queueMicrotask'));

Promise.resolve().then(() => console.log('Promise.then'));

console.log('2');

// 输出顺序:1 → 2 → nextTick → queueMicrotask → Promise.then

区别
- process.nextTickNode.js 独有,优先级最高,会在当前操作结束后立即执行
- queueMicrotask标准 API,浏览器也支持,在微任务队列尾部执行
- Promise.then:在微任务队列中

3.4.5 setImmediate:和 setTimeout(0) 一样吗?

不一样! 关键区别在执行阶段:

// 场景1:在I/O操作内部调用
const fs = require('fs');

fs.readFile(__filename, () => {
console.log('file read');

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

// 在I/O回调中,setImmediate 通常先于 setTimeout 执行
});

// 场景2:主模块直接调用(顺序不确定,取决于系统性能)
console.log('start');
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 结论:主模块中两者顺序不确定!

为什么 I/O 回调中 setImmediate 更可靠?

因为在 poll 阶段(I/O 操作完成后)会进入 check 阶段执行 setImmediate,而 setTimeout 还需要等回到 timers 阶段。

配图2 - 配图2


🔥 实战:3个递进项目

项目 1:观察事件循环顺序(5分钟)

目标:亲眼看看宏任务和微任务的执行顺序

// event_loop_observer.js
// 这段代码帮助你理解事件循环的实际执行顺序

console.log('=== 同步代码开始 ===');

setTimeout(() => {
console.log('【宏任务1】setTimeout 1 - timers阶段');
}, 0);

setTimeout(() => {
console.log('【宏任务2】setTimeout 2 - timers阶段');
}, 0);

Promise.resolve()
.then(() => {
console.log('【微任务1】Promise.then 1');
})
.then(() => {
console.log('【微任务2】Promise.then 2(在第一个then之后)');
});

queueMicrotask(() => {
console.log('【微任务3】queueMicrotask 1');
});

process.nextTick(() => {
console.log('【微任务4】process.nextTick(最高优先级)');
});

console.log('=== 同步代码结束 ===');

预期输出

=== 同步代码开始 ===
=== 同步代码结束 ===
【微任务4】process.nextTick(最高优先级)
【微任务1】Promise.then 1
【微任务3】queueMicrotask 1
【微任务2】Promise.then 2(在第一个then之后)
【宏任务1】setTimeout 1 - timers阶段
【宏任务2】setTimeout 2 - timers阶段

解释:同步代码 → nextTick → Promise.then → queueMicrotask → setTimeout


项目 2:模拟"下载管理器"(15分钟)

目标:用事件循环知识处理多个异步下载任务

// download_manager.js
// 模拟一个文件下载器,按顺序报告下载状态

const fs = require('fs');

class DownloadManager {
constructor() {
this.tasks = [];
this.completed = 0;
}

addTask(name, delay) {
this.tasks.push({ name, delay });
}

async downloadAll() {
console.log(`📥 开始下载 ${this.tasks.length} 个文件...\n`);

const promises = this.tasks.map((task, index) => 
  new Promise((resolve) => {
    // 使用 setTimeout 模拟下载延迟
    setTimeout(() => {
      console.log(`✅ 文件${index + 1} [${task.name}] 下载完成(耗时${task.delay}ms)`);
      this.completed++;

        // 微任务:在每次下载完成后立即更新UI
      process.nextTick(() => {
        console.log(`   📊 当前进度:${this.completed}/${this.tasks.length}`);
      });

      resolve();
    }, task.delay);
  })
);

// 等待所有下载完成
await Promise.all(promises);

// 所有任务完成后,用 setImmediate 通知
setImmediate(() => {
  console.log('\n🎉 所有文件下载完成!');
});
}
}

// 使用示例
const manager = new DownloadManager();

manager.addTask('document.pdf', 200);
manager.addTask('image.png', 100);  // 这个会先完成
manager.addTask('video.mp4', 300);

manager.downloadAll();

预期输出

📥 开始下载 3 个文件...

✅ 文件2 [image.png] 下载完成(耗时100ms)
 当前进度:1/3
✅ 文件1 [document.pdf] 下载完成(耗时200ms)
 当前进度:2/3
✅ 文件3 [video.mp4] 下载完成(耗时300ms)
 当前进度:3/3

🎉 所有文件下载完成!

解释:文件虽然下载时间不同,但通过 Promise.all 统一管理,process.nextTick 确保每个完成状态立即显示。


项目 3:爬虫片段 - 并发控制 + 顺序输出(15分钟)

目标:综合运用事件循环知识,实现一个带并发控制的网页抓取模拟

// crawler_with_control.js
// 模拟爬虫:最多同时爬3个页面,按添加顺序输出结果

class Crawler {
constructor(maxConcurrent = 3) {
this.maxConcurrent = maxConcurrent;
this.running = 0;
this.results = [];
this.queue = [];
}

// 模拟抓取一个页面
async crawl(url) {
return new Promise((resolve) => {
  const delay = Math.random() * 2000 + 500; // 0.5-2.5秒随机延迟
  console.log(`🔄 开始爬取: ${url} (预计${Math.round(delay)}ms)`);

  setTimeout(() => {
    const title = `页面内容 - ${url.split('//')[1] || url}`;
    resolve({ url, title, delay: Math.round(delay) });
  }, delay);
});
}

// 添加任务到队列
async addUrl(url) {
// 如果当前运行的任务已达上限,等待
if (this.running >= this.maxConcurrent) {
  await new Promise(resolve => this.queue.push(resolve));
}

this.running++;

// 立即开始爬取
const result = await this.crawl(url);
this.results.push(result);

console.log(`📝 记录结果: ${result.title} (实际耗时${result.delay}ms)`);

this.running--;

// 如果队列中有等待的任务,启动下一个
if (this.queue.length > 0) {
  const next = this.queue.shift();
  // 用 setImmediate 确保按顺序执行
  setImmediate(next);
}

// 微任务:每次爬取完成后尝试输出有序结果
process.nextTick(() => this.tryOutputResults());
}

// 尝试按顺序输出结果
tryOutputResults() {
const sorted = [...this.results].sort((a, b) => a.delay - b.delay);
const nextIndex = this.results.length - this.running - 1;

if (nextIndex >= 0 && sorted[nextIndex]) {
  // 这里只是演示,实际爬虫会保存到文件
}
}
}

// 运行爬虫
async function main() {
const crawler = new Crawler(2); // 最多同时2个

const urls = [
'https://example.com/page1',
'https://example.com/page2', 
'https://example.com/page3',
'https://example.com/page4',
'https://example.com/page5'
];

console.log('🕷️ 爬虫启动,最多同时爬取2个页面...\n');

// 一次性添加所有URL
urls.forEach(url => crawler.addUrl(url));

// 等待一段时间后检查结果
await new Promise(resolve => setTimeout(resolve, 6000));

console.log('\n📊 爬取统计:');
console.log(`   总页面数: ${urls.length}`);
console.log(`   完成数: ${crawler.results.length}`);

setImmediate(() => {
console.log('🏁 爬虫任务完成!');
});
}

main();

预期输出(随机性较大):

🕷️ 爬虫启动,最多同时爬取2个页面...

🔄 开始爬取: https://example.com/page1 (预计1847ms)
🔄 开始爬取: https://example.com/page2 (预计923ms)
📝 记录结果: 页面内容 - example.com/page2 (实际耗时923ms)
🔄 开始爬取: https://example.com/page3 (预计1562ms)
📝 记录结果: 页面内容 - example.com/page1 (实际耗时1847ms)
🔄 开始爬取: https://example.com/page4 (预计743ms)

📝 记录结果: 页面内容 - example.com/page3 (实际耗时1562ms)
🔄 开始爬取: https://example.com/page5 (预计1105ms)
📝 记录结果: 页面内容 - example.com/page4 (实际耗时743ms)
📝 记录结果: 页面内容 - example.com/page5 (实际耗时1105ms)

📊 爬取统计:
页面数: 5
成数: 5
🏁 爬虫任务完成!

解释:通过控制并发数 + setImmediate + process.nextTick,实现了高效的爬取调度。


💪 进阶:常见坑 + 性能小贴士

❌ 坑1:误以为 setTimeout(fn, 0) 会立即执行

// ❌ 错误理解
setTimeout(() => console.log('立即!'), 0);
console.log('这个会先输出');

// ✅ 正确理解:0ms 实际上是 1ms+,还是要排队

❌ 坑2:在 for 循环中误用 await 导致串行

// ❌ 错误:串行执行,耗时 = 100+200+300 = 600ms
for (const url of urls) {
await fetch(url);
}

// ✅ 正确:并行执行,耗时 = max(100,200,300) = 300ms
await Promise.all(urls.map(url => fetch(url)));

❌ 坑3:process.nextTick 中抛出异常不会触发 unhandledRejection

// ❌ 危险:nextTick 中的异常很难被捕获
process.nextTick(() => {
throw new Error('这个错误不会被 promise catch 捕获');
});

// ✅ 安全:使用 queueMicrotask 或在 async 函数中 await
queueMicrotask(async () => {
throw new Error('这个可以被捕获');
});

❌ 坑4:混淆 setImmediate 和 setTimeout(fn, 0)

场景 推荐 原因
主模块(不在I/O回调中) 都行 顺序不确定
I/O 回调中 setImmediate 更可靠,保证在 poll 后执行
需要精确延迟 setTimeout 更通用

❌ 坑5:微任务中无限递归

// ❌ 危险:微任务无限循环会阻塞事件循环
async function infinite() {
await Promise.resolve();
infinite();
}

// ✅ 安全:定期让出控制权给宏任务
async function safeInfinite() {
await new Promise(resolve => setTimeout(resolve, 0));
safeInfinite();
}

🚀 性能小贴士:合理分配任务类型

  • 高优先级:用 process.nextTick(但别滥用)
  • 普通优先级:用 Promise.then
  • 需要等待I/O后执行:用 setImmediate
  • 需要延迟:用 setTimeout

🔧 调试技巧:打印事件循环状态

// 在关键位置插入调试日志
function logPhase(label) {
const phase = 
' timers' + 
'|pending|' + 
'idle|prepare|' + 
'poll|' + 
'check|' + 
'close';
console.log(`[${label}] 当前阶段: ${phase}`);
}

// 使用示例
setTimeout(() => {
logPhase('setTimeout回调');
}, 0);

Promise.resolve().then(() => {
logPhase('Promise.then');
});

process.nextTick(() => {
logPhase('nextTick');
});

✏️ 练习题

练习 1(2分钟):预测输出顺序

console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
console.log('D');
  • 输入:(直接运行)
  • 预期输出:?
  • 提示:同步代码 → 微任务 → 宏任务

练习 2(2分钟):加条件判断

console.log('start');
setTimeout(() => console.log('timeout'), 0);
const p = Promise.resolve();
p.then(() => console.log('then1'));
p.then(() => console.log('then2'));
  • 输入:(直接运行)
  • 预期输出:?
  • 提示:同一个 Promise 的 then 会链式执行

练习 3(5分钟):处理新数据类型

  • 输入:添加一个 setImmediate 回调,输出 "immediate"
  • 预期输出:能看到 immediate 在 timeout 之前或之后
  • 提示:在 I/O 回调中 setImmediate 更容易先执行

练习 4(10分钟):组合项目能力

把项目 1 和项目 2 结合起来,实现一个带进度通知的下载器

练习 5(5分钟):分析报错

process.nextTick(() => {
throw 'error in nextTick';
});
Promise.reject('oops');
  • 问题:这个 Promise.reject 会怎样?process.nextTick 中的异常谁来捕获?
  • 提示:nextTick 异常是同步抛出的

作业:做一个「事件循环可视化工具」

需求:用 Node.js 写一个工具,可视化展示事件循环执行过程

功能点
1. 输入一段异步代码(用对象描述)
2. 按事件循环顺序输出执行过程
3. 标注每个任务属于哪个阶段(timers/check/poll等)

加分项
1. 支持生成执行流程图(ASCII art也行)
2. 支持对比「串行」vs「并行」的执行时间差异

验收标准
- 能正确区分微任务和宏任务
- 能按正确顺序输出执行过程
- 代码有注释,解释为什么是这个顺序


📚 总结

本文学到的 3 个核心点
1. 事件循环分 6 个阶段,宏任务在阶段末尾执行,微任务在阶段之间执行
2. process.nextTick > queueMicrotaskPromise.then > setTimeout > setImmediate
3. I/O 回调中 setImmediate 更可靠,主模块中两者顺序不确定

延伸学习资源
- 官方文档:https://nodejs.org/api/globals.html (process.nextTick 和 queueMicrotask)
- 经典文章:《The Node.js Event Loop, Timers, and process.nextTick》
- 视频:Node.js 官方发布的 Event Loop 动画演示

互动钩子

你有没有被事件循环坑过的经历?比如线上出了 bug,但本地怎么都复现不了?或者你有什么记忆小技巧?评论区聊聊,老粉优先回复!


下章预告
学会了事件循环原理,下一章我们来学习 util.promisify —— 一个能把「回调风格」代码一键变成「Promise 风格」的神器……

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。