内容小结

41 阅读9分钟

先用一个超级形象的比喻把全貌抓住

想象一家超级忙碌的快递公司(Node.js服务器),每天要处理成千上万份快递(用户请求),其中很多是“去仓库取货”(读文件、查数据库、网络请求等I/O操作)。

如果用传统阻塞方式(像Java/PHP常见做法):

  • 每个快递员(线程)接到一个取货任务,就亲自跑到仓库门口干等货取出来。
  • 等的人多了,公司就需要雇成千上万的快递员。
  • 快递员大部分时间都在“干等”,工资(内存、CPU)花得飞起,公司很快就破产了。

Node的异步方式(天才设计):

  • 公司只雇1个快递员(单线程,主事件循环)。
  • 还有一个**小团队(libuv线程池,默认4人)**专门负责去仓库取货。
  • 一个**智能前台(C++绑定层)**负责接单和派单。
  • 流程是这样的:
  1. 用户下单“帮我取仓库里的货物A”(你写 fs.readFile('a.txt', callback))
  2. 快递员(主线程)立刻在前台登记:
    “货物A,送达后请呼叫我这个电话(回调函数)”
  3. 前台创建一个快递单(请求对象),写上取货地址、电话,交给小团队或直接用仓库的自动系统。
  4. 快递员(主线程)不等!立刻去处理下一个订单
  5. 小团队取到货后,把货和快递单放回前台,并通知“有货到了”。
  6. 快递员下次巡岗时看到前台有完成的快递单,就拨电话(执行你的callback),把货交给用户。

这样,一个快递员 + 少量后勤就能处理海量订单!

现在用技术术语把这个比喻对应起来(核心四件套)

Node异步I/O的全过程就靠这四件东西协作:

比喻角色真实技术名称作用
快递员(只有一个)事件循环(Event Loop)主线程,不断循环检查“有没有完成的事”。由libuv驱动。
观察哨兵观察者(Observer)每个阶段都有人站岗盯着特定类型的事件(如定时器、I/O、网络)。
快递单请求对象(Request Object)C++结构体,封装了你的请求参数 + 回调,从JS层传到底层,再带结果回来。
后勤小团队libuv线程池 + 系统异步API真正干活的:文件I/O进线程池,网络I/O用系统异步(epoll/IOCP等)。

一步步详细走完一次 fs.readFile 的底层之旅

我们以最常见的文件读取为例,走完整个流程:

  1. 你在JS里写

    fs.readFile('big.txt', (err, data) => {
      console.log('读完了');
    });
    console.log('我先继续干别的');  // 这行会立刻打印!
    
  2. V8执行到fs.readFile

    • 这其实是Node内置的C++函数(不是纯JS)。
    • C++层收到路径和你的回调函数。
  3. C++层创建“快递单”(请求对象)

    • new 一个 uv_fs_t 结构体(继承自 uv_req_t)。
    • 填入:文件路径、读取flags、缓冲区、你的JS回调(包装成C函数)。
  4. 交给libuv干活

    • 调用 uv_fs_read()。
    • 因为文件I/O大多数系统不支持真正异步,所以扔进libuv线程池(默认4线程)。
    • 主线程(事件循环)立刻返回,继续执行后面的JS代码(所以你看到“我先继续干别的”立刻打印)。
  5. 线程池里的工人真正读文件

    • 用系统调用 read() 读取磁盘。
    • 读完后,把数据和结果填回请求对象。
  6. 工人把完成通知邮寄回主线程

    • libuv把这个完成事件放入 poll 阶段的队列。
    • (不能直接调用JS回调,因为V8是单线程的)
  7. 事件循环巡岗到poll阶段

    • 发现有I/O观察者就绪。
    • 执行关联的C++回调。
    • C++回调最终通过 V8 调用你的JS回调:
      callback(err, data);  // 打印 '读完了'
      

整个过程主线程从未阻塞!

事件循环的6个阶段(简单记法)

事件循环像钟表一样一圈圈转,每圈经过6个岗亭:

  1. timers:到点的setTimeout/setInterval回调
  2. pending callbacks:一些系统级的回调
  3. idle/prepare:libuv内部用
  4. poll:★ 最忙的岗亭 ★
    • 等I/O完成(文件、网络等)
    • 有完成事件就执行回调,没事就可能稍微等一等
  5. check:setImmediate回调
  6. close:socket关闭等回调

另外还有两个特别优先的:

  • nextTick队列:比任何阶段都快,本轮循环立刻执行
  • Promise微任务:每阶段结束后执行

为什么Node单线程还能高并发?

因为:

  • 真正的“慢活”(I/O)交给线程池或操作系统异步去做。
  • 主线程只负责“接单、分发、收货通知”,永远不卡住。
  • 一个主线程轻松管上万个连接。

常见误区澄清

  • “Node是单线程” → 错!JS主线程单线程,但有libuv线程池(文件、DNS、加密等用)。
  • “所有异步都进线程池” → 错!网络I/O在Linux用epoll直接异步,不占线程池。
  • “回调一定在下一轮事件循环执行” → 不一定,nextTick是本轮。

好!我们用几个简单但经典的代码示例,来直观演示 Node.js 的事件循环(Event Loop)到底是怎么工作的。这些例子会清晰展示:

  • 不同类型任务的执行顺序(setTimeout、setImmediate、process.nextTick、Promise、I/O 回调)
  • 事件循环的阶段优先级
  • 微任务(microtasks)比宏任务(macrotasks)优先

示例 1:基本阶段顺序(timers → poll → check)

console.log('1. 脚本开始');

setTimeout(() => {
  console.log('2. setTimeout 回调');
}, 0);

setImmediate(() => {
  console.log('3. setImmediate 回调');
});

console.log('4. 脚本结束');

// 输出顺序:
// 1. 脚本开始
// 4. 脚本结束
// 3. setImmediate 回调   ← 先执行(check阶段)
// 2. setTimeout 回调      ← 后执行(timers阶段,下一次循环)

为什么 setImmediate 先于 setTimeout(0)?

  • 当前脚本执行完后,事件循环第一轮进入:
    • timers 阶段:检查有到期的 timer,但 setTimeout(0) 实际延迟至少 4ms(现代Node有优化,但仍可能在下一轮)
    • poll 阶段:如果没有其他I/O,快速跳过
    • check 阶段:执行 setImmediate
  • 然后进入下一轮循环,才执行 timers 阶段的 setTimeout

(注意:在某些情况下 setTimeout(0) 可能先执行,但 setImmediate 更可靠地在本轮执行完)

示例 2:微任务优先级最高(nextTick 和 Promise)

console.log('1. 开始');

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

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

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

console.log('5. 结束');

// 输出顺序:
// 1. 开始
// 5. 结束
// 4. nextTick          ← 微任务,本轮循环立刻执行
// 3. Promise.then      ← 微任务,nextTick 之后
// 2. setTimeout        ← 宏任务,下一轮循环

关键点

  • process.nextTick 和 Promise.then 是微任务(microtasks)
  • 它们在当前宏任务结束后、下一轮事件循环开始前立即执行
  • nextTick 比 Promise.then 优先级更高

示例 3:I/O 回调在 poll 阶段执行

const fs = require('fs');

console.log('1. 开始');

fs.readFile(__filename, () => {
  console.log('2. 文件读取完成(I/O回调)');
  
  setTimeout(() => console.log('3. I/O后的setTimeout'), 0);
  setImmediate(() => console.log('4. I/O后的setImmediate'));
});

setTimeout(() => console.log('5. 外层setTimeout'), 0);

console.log('6. 结束');

// 常见输出顺序:
// 1. 开始
// 6. 结束
// 5. 外层setTimeout
// 2. 文件读取完成(I/O回调)
// 4. I/O后的setImmediate   ← 在poll阶段执行完后,进入check阶段
// 3. I/O后的setTimeout     ← 下一轮timers阶段

解释

  • 文件读取是异步I/O,回调注册到 poll 阶段
  • 当文件读完,事件循环在 poll 阶段执行这个回调
  • 在回调内部写的 setImmediate 会在当前轮的 check 阶段执行
  • setTimeout 则要等下一轮

示例 4:nextTick 的“饥饿”风险(慎用递归)

console.log('开始');

process.nextTick(() => {
  console.log('nextTick 1');
  process.nextTick(() => {
    console.log('nextTick 2');
    process.nextTick(() => {
      console.log('nextTick 3');
    });
  });
});

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

console.log('结束');

// 输出:
// 开始
// 结束
// nextTick 1
// nextTick 2
// nextTick 3
// setTimeout   ← 被“饿”了很久才执行!

警告:递归调用 process.nextTick 会阻塞事件循环,让定时器、I/O 回调迟迟得不到执行!这是“回调地狱”的一种极端情况。

总结:事件循环执行顺序记忆口诀(2025年Node最新规则)

  1. 当前同步代码执行完
  2. 执行所有 process.nextTick 队列(直到清空)
  3. 执行所有 Promise.then 微任务(直到清空)
  4. 进入下一轮事件循环:
    • timers(setTimeout/setInterval)
    • pending callbacks
    • idle/prepare(内部)
    • poll(I/O 回调最常在这里执行)
    • check(setImmediate)
    • close callbacks
  5. 每阶段结束后,再检查是否有新的微任务(nextTick/Promise)

我们来一个结合真实网络请求的完整代码示例!这个例子会同时涉及:

  • 同步代码
  • 微任务(nextTick、Promise)
  • 宏任务(setTimeout、setImmediate)
  • 文件I/O(fs.readFile,进线程池)
  • 网络I/O(http.get,真实HTTP请求,用系统异步,不占线程池)

这个例子超级经典,能让你看到事件循环在处理真实网络请求时的顺序(网络回调通常在poll阶段执行)。

代码(你可以复制到 Node.js 环境运行试试)

const http = require('http');
const fs = require('fs');

console.log('1. 脚本开始执行');

setTimeout(() => {
  console.log('2. 外层 setTimeout (0ms)');
}, 0);

setImmediate(() => {
  console.log('3. 外层 setImmediate');
});

process.nextTick(() => {
  console.log('4. 外层 nextTick');
});

Promise.resolve().then(() => {
  console.log('5. 外层 Promise.then');
});

fs.readFile(__filename, () => {
  console.log('6. 文件读取回调 (文件I/O完成)');

  setTimeout(() => console.log('7. 文件回调里的 setTimeout'), 0);
  setImmediate(() => console.log('8. 文件回调里的 setImmediate'));
  process.nextTick(() => console.log('9. 文件回调里的 nextTick'));
});

http.get('http://nodejs.org/dist/latest/SHASUMS256.txt', (res) => {
  let data = '';
  res.on('data', (chunk) => { data += chunk; });
  res.on('end', () => {
    console.log('10. 网络请求完成回调 (HTTP I/O完成)');

    setTimeout(() => console.log('11. 网络回调里的 setTimeout'), 0);
    setImmediate(() => console.log('12. 网络回调里的 setImmediate'));
    process.nextTick(() => console.log('13. 网络回调里的 nextTick'));
  });
});

console.log('14. 脚本同步部分结束');

典型的输出顺序(实际运行可能略有差异,但大体一致)

1. 脚本开始执行
14. 脚本同步部分结束
4. 外层 nextTick
5. 外层 Promise.then
3. 外层 setImmediate          ← check阶段
2. 外层 setTimeout (0ms)       ← 下一轮timers
6. 文件读取回调 (文件I/O完成)  ← poll阶段(文件很快读完)
9. 文件回调里的 nextTick
8. 文件回调里的 setImmediate
7. 文件回调里的 setTimeout
10. 网络请求完成回调 (HTTP I/O完成)  ← poll阶段(网络请求稍慢,通常在文件后)
13. 网络回调里的 nextTick
12. 网络回调里的 setImmediate
11. 网络回调里的 setTimeout

为什么是这个顺序?关键解释(结合网络请求)

  • 同步代码先执行(1 和 14)。
  • 微任务立即清空(4 和 5)。
  • 外层宏任务:setImmediate 先(check阶段),setTimeout 后(下一轮)。
  • 文件I/O回调(6)通常很快出现(本地文件读几乎瞬间完成),在poll阶段执行。
    • 它内部的 nextTick 最先(微任务)。
    • 然后 setImmediate(当前轮check)。
    • setTimeout 等下一轮。
  • 网络I/O回调(10)通常稍慢(需要真实网络下载SHASUMS256.txt文件,大小几十KB)。
    • 它在poll阶段执行,通常在文件I/O之后。
    • 内部任务顺序同上:nextTick → setImmediate → setTimeout。

真实场景启发

  • 在Web服务器中,**网络请求(如客户端连接、数据库查询)**的回调就在poll阶段。
  • 快速响应不会被慢网络阻塞——事件循环继续转,其他请求照样处理。
  • 这就是Node高并发的秘密:网络I/O用系统异步(epoll/IOCP),不占主线程!