1. 首页
  2. /
  3. 60天Node.js
  4. /
  5. Day 05
🌱 阶段 1 · Node.js 核心

Day 05

事件循环深度解析

📋 今日目标

  • 彻底理解 Node.js 事件循环的六个阶段
  • 搞清 setTimeout vs setImmediate vs process.nextTick 的执行顺序
  • 理解 libuv 和 I/O 多路复用
  • 明白"Node.js 是单线程但能处理高并发"的真正含义

📖 核心知识点

1. 前端vs后端:事件循环的差异

你在前端已经理解了"宏任务和微任务"的概念,但Node.js 的事件循环比浏览器更复杂

浏览器事件循环(你熟悉的):

宏任务 → 清空微任务队列 → 渲染 → 宏任务 → ...

Node.js 事件循环(今天要学的):

六个阶段循环执行,每个阶段之间清空微任务队列

2. Node.js 事件循环的六个阶段

   ┌───────────────────────────┐
┌─>│         timers             │ ← setTimeout / setInterval 回调
│  └──────────┬────────────────┘
│  ┌──────────┴────────────────┐
│  │     pending callbacks     │ ← 系统级回调(TCP 错误等)
│  └──────────┬────────────────┘
│  ┌──────────┴────────────────┐
│  │       idle, prepare       │ ← 仅内部使用
│  └──────────┬────────────────┘
│  ┌──────────┴────────────────┐
│  │          poll              │ ← I/O 回调(文件读取、网络请求等)
│  └──────────┬────────────────┘
│  ┌──────────┴────────────────┐
│  │         check             │ ← setImmediate 回调
│  └──────────┬────────────────┘
│  ┌──────────┴────────────────┐
│  │     close callbacks       │ ← socket.on('close', ...) 等
│  └──────────┬────────────────┘
│             │
└─────────────┘

每个阶段的关键

  1. Timers:执行 setTimeoutsetInterval 到期的回调
  2. Pending Callbacks:执行推迟到下一轮的系统级回调
  3. Idle/Prepare:内部使用,你不需要关心
  4. Poll:核心阶段!获取新的 I/O 事件,执行 I/O 相关回调
  5. Check:执行 setImmediate 回调
  6. Close Callbacks:执行关闭事件的回调

微任务在回调之后执行

Timer 回调 → [清空 nextTick 队列] → [清空 Promise/queueMicrotask 队列] → 下一个回调 → ...

Node 11+ 之后,微任务的清空时机更接近浏览器:每个 JS 回调执行完都会进入一次 microtask checkpoint。 旧版 Node 中,很多资料会描述为“每个阶段结束后清空微任务”,这是偏旧版本的简化说法。

3. process.nextTick vs setImmediate vs setTimeout

这是面试最高频的问题之一:

// 测试 1:在 CommonJS 主模块中(例如 node test.cjs 或默认未开启 type: module 的 .js)
console.log('1. 同步代码');

setTimeout(() => console.log('2. setTimeout'), 0);
setImmediate(() => console.log('3. setImmediate'));
process.nextTick(() => console.log('4. nextTick'));
Promise.resolve().then(() => console.log('5. Promise'));

console.log('6. 同步代码');

// 输出顺序(在 CommonJS 顶层确定):
// 1. 同步代码
// 6. 同步代码
// 4. nextTick         ← Node 的 nextTick 队列先清空
// 5. Promise          ← V8 microtask 队列随后清空
// 2. setTimeout       ← 可能在 setImmediate 前或后
// 3. setImmediate     ← 可能在 setTimeout 前或后

如果把同一段代码放在 ESM 顶层(例如 node test.mjsnode --input-type=module, 或项目 package.json 中设置了 "type": "module"),Promise 会先于 process.nextTick

// ESM 顶层输出:
// 1. 同步代码
// 6. 同步代码
// 5. Promise
// 4. nextTick
// 2. setTimeout       ← 可能在 setImmediate 前或后
// 3. setImmediate     ← 可能在 setTimeout 前或后

这是因为 ESM 模块的顶层执行本身已经处在 microtask 处理流程中,Node 不会在当前 Promise/queueMicrotask 队列排空前插队执行新注册的 nextTick

⚠️ 在主模块中,setTimeout(fn, 0)setImmediate 的顺序不确定。 但在 I/O 回调中,setImmediate 总是先于 setTimeout

// 测试 2:在 I/O 回调中(顺序确定!)
import fs from 'node:fs';

fs.readFile('./package.json', () => {
  setTimeout(() => console.log('setTimeout'), 0);
  setImmediate(() => console.log('setImmediate'));
});

// 输出顺序(始终确定):
// setImmediate    ← I/O 回调在 poll 阶段执行,下一个阶段是 check
// setTimeout      ← check 之后回到 timer

顺序总结:

同步代码
→ microtask checkpoint:CommonJS 顶层和普通回调中,process.nextTick 通常先于 Promise.then / queueMicrotask
→ 事件循环阶段:timers / poll / check / close callbacks,具体顺序取决于所处阶段和调度位置

注意:process.nextTick 的“更高优先级”不是所有场景都绝对成立。 例如 ESM 顶层代码已经运行在微任务上下文中,Promise/queueMicrotask 可能先于 nextTick; 在 Promise 回调内部注册的 nextTick,也不会插队到当前 Promise 链的下一个 .then() 之前。

4. process.nextTick 的特殊性

process.nextTick 不属于事件循环的任何阶段。它在当前操作完成后、事件循环继续之前立即执行:

// nextTick 递归可能阻塞事件循环!
function recursiveNextTick(count = 0) {
  if (count < 5) {
    process.nextTick(() => {
      console.log(`nextTick ${count}`);
      recursiveNextTick(count + 1);
    });
  }
}

recursiveNextTick();
setImmediate(() => console.log('setImmediate'));

// 输出:先执行所有 nextTick,然后才轮到 setImmediate
// nextTick 0
// nextTick 1
// nextTick 2
// nextTick 3
// nextTick 4
// setImmediate

// ⚠️ 如果 nextTick 递归不终止,setImmediate 永远不会执行!
// 这就是"nextTick 饥饿"(starvation)问题

最佳实践

  • process.nextTick:用于在当前操作后立即执行(如确保回调异步化)
  • setImmediate:用于不需要立即执行的异步操作(更安全)

5. libuv 与 I/O 多路复用

Node.js 的异步 I/O 能力来自 libuv 库:

┌─────────────────────────┐
│      Node.js 应用代码     │   ← JavaScript(单线程)
├─────────────────────────┤
│        V8 引擎           │   ← 编译和执行 JS
├─────────────────────────┤
│    Node.js C++ Bindings  │   ← JS 与 C++ 的桥梁
├─────────────────────────┤
│         libuv            │   ← 异步 I/O 库
│  ┌───────┬──────────┐   │
│  │ 事件循环 │ 线程池(4) │   │   ← I/O 多路复用 + 工作线程
│  └───────┴──────────┘   │
├─────────────────────────┤
│    操作系统(epoll/kqueue) │   ← 内核级 I/O
└─────────────────────────┘

为什么单线程能处理高并发?

关键在于 I/O 多路复用(epoll/kqueue/IOCP):

  1. Node.js 不为每个连接创建线程(不像传统 Java/PHP)
  2. 所有网络 I/O 由操作系统内核异步处理
  3. libuv 通过 epoll(Linux)/ kqueue(macOS)监听多个 I/O 事件
  4. 当 I/O 完成时,内核通知 libuv,libuv 触发 JS 回调
  5. CPU 密集型任务使用 libuv 的线程池(默认 4 个线程)
// 这就是为什么 Node.js 能同时处理数千个连接
import http from 'node:http';

const server = http.createServer((req, res) => {
  // 每个请求不会创建新线程
  // 而是注册在事件循环中,等 I/O 完成后执行回调
  res.end('Hello!');
});

server.listen(3000);
// 单个 Node.js 进程就能处理 10,000+ 并发连接

6. 阻塞事件循环的危险

import http from 'node:http';

const server = http.createServer((req, res) => {
  if (req.url === '/slow') {
    // ❌ 糟糕!这会阻塞事件循环
    // 在这 5 秒内,所有其他请求都无法处理
    const start = Date.now();
    while (Date.now() - start < 5000) {}
    res.end('慢请求完成');
  } else {
    res.end('快速响应');
  }
});

server.listen(3000);

// 当 /slow 在执行时,即使访问 / 也会等待
// 这就是为什么 Node.js 中不能有 CPU 密集型同步操作

解决方案

  • 使用 worker_threads(Day 13 详解)
  • 将 CPU 密集型任务交给独立服务
  • 使用流式处理替代一次性大量计算

💻 实践练习

练习 1:事件循环顺序预测

预测以下代码的输出顺序,然后运行验证:

console.log('start');

setTimeout(() => console.log('timeout1'), 0);
setTimeout(() => console.log('timeout2'), 0);

Promise.resolve()
  .then(() => {
    console.log('promise1');
    process.nextTick(() => console.log('nextTick inside promise'));
  })
  .then(() => console.log('promise2'));

process.nextTick(() => console.log('nextTick1'));
process.nextTick(() => {
  console.log('nextTick2');
  process.nextTick(() => console.log('nextTick3'));
});

setImmediate(() => console.log('immediate1'));
setImmediate(() => {
  console.log('immediate2');
  process.nextTick(() => console.log('nextTick inside immediate'));
  Promise.resolve().then(() => console.log('promise inside immediate'));
});

console.log('end');

练习 2:事件循环可视化工具

编写一个 event-loop-visualizer.js,用定时追踪的方式可视化事件循环的执行过程:

// 注册不同阶段的回调,用带时间戳的日志输出执行顺序
// 展示微任务如何在阶段之间执行

练习 3:阻塞检测器

编写一个工具函数,检测事件循环是否被阻塞:

function createBlockDetector(thresholdMs = 100) {
  // 用 setInterval 检测
  // 如果两次回调间隔超过 thresholdMs,说明事件循环被阻塞
  // 报告阻塞持续时间
}

✅ 今日产出

  • 能画出 Node.js 事件循环的六个阶段图
  • 能正确预测 nextTick/Promise/setTimeout/setImmediate 的执行顺序
  • 理解 libuv 和 I/O 多路复用的原理
  • 完成练习 1(顺序预测)
  • 完成练习 2(事件循环可视化)
  • 完成练习 3(阻塞检测器)

📚 延伸阅读


⬅️ Day 04 — 核心模块(下) | ➡️ Day 06 — 异步编程模式

本文内容来自 源仓库 day-05/README.md