先用一个超级形象的比喻把全貌抓住
想象一家超级忙碌的快递公司(Node.js服务器),每天要处理成千上万份快递(用户请求),其中很多是“去仓库取货”(读文件、查数据库、网络请求等I/O操作)。
如果用传统阻塞方式(像Java/PHP常见做法):
- 每个快递员(线程)接到一个取货任务,就亲自跑到仓库门口干等货取出来。
- 等的人多了,公司就需要雇成千上万的快递员。
- 快递员大部分时间都在“干等”,工资(内存、CPU)花得飞起,公司很快就破产了。
Node的异步方式(天才设计):
- 公司只雇1个快递员(单线程,主事件循环)。
- 还有一个**小团队(libuv线程池,默认4人)**专门负责去仓库取货。
- 一个**智能前台(C++绑定层)**负责接单和派单。
- 流程是这样的:
- 用户下单“帮我取仓库里的货物A”(你写 fs.readFile('a.txt', callback))
- 快递员(主线程)立刻在前台登记:
“货物A,送达后请呼叫我这个电话(回调函数)” - 前台创建一个快递单(请求对象),写上取货地址、电话,交给小团队或直接用仓库的自动系统。
- 快递员(主线程)不等!立刻去处理下一个订单。
- 小团队取到货后,把货和快递单放回前台,并通知“有货到了”。
- 快递员下次巡岗时看到前台有完成的快递单,就拨电话(执行你的callback),把货交给用户。
这样,一个快递员 + 少量后勤就能处理海量订单!
现在用技术术语把这个比喻对应起来(核心四件套)
Node异步I/O的全过程就靠这四件东西协作:
| 比喻角色 | 真实技术名称 | 作用 |
|---|---|---|
| 快递员(只有一个) | 事件循环(Event Loop) | 主线程,不断循环检查“有没有完成的事”。由libuv驱动。 |
| 观察哨兵 | 观察者(Observer) | 每个阶段都有人站岗盯着特定类型的事件(如定时器、I/O、网络)。 |
| 快递单 | 请求对象(Request Object) | C++结构体,封装了你的请求参数 + 回调,从JS层传到底层,再带结果回来。 |
| 后勤小团队 | libuv线程池 + 系统异步API | 真正干活的:文件I/O进线程池,网络I/O用系统异步(epoll/IOCP等)。 |
一步步详细走完一次 fs.readFile 的底层之旅
我们以最常见的文件读取为例,走完整个流程:
-
你在JS里写:
fs.readFile('big.txt', (err, data) => { console.log('读完了'); }); console.log('我先继续干别的'); // 这行会立刻打印! -
V8执行到fs.readFile:
- 这其实是Node内置的C++函数(不是纯JS)。
- C++层收到路径和你的回调函数。
-
C++层创建“快递单”(请求对象):
- new 一个 uv_fs_t 结构体(继承自 uv_req_t)。
- 填入:文件路径、读取flags、缓冲区、你的JS回调(包装成C函数)。
-
交给libuv干活:
- 调用 uv_fs_read()。
- 因为文件I/O大多数系统不支持真正异步,所以扔进libuv线程池(默认4线程)。
- 主线程(事件循环)立刻返回,继续执行后面的JS代码(所以你看到“我先继续干别的”立刻打印)。
-
线程池里的工人真正读文件:
- 用系统调用 read() 读取磁盘。
- 读完后,把数据和结果填回请求对象。
-
工人把完成通知邮寄回主线程:
- libuv把这个完成事件放入 poll 阶段的队列。
- (不能直接调用JS回调,因为V8是单线程的)
-
事件循环巡岗到poll阶段:
- 发现有I/O观察者就绪。
- 执行关联的C++回调。
- C++回调最终通过 V8 调用你的JS回调:
callback(err, data); // 打印 '读完了'
整个过程主线程从未阻塞!
事件循环的6个阶段(简单记法)
事件循环像钟表一样一圈圈转,每圈经过6个岗亭:
- timers:到点的setTimeout/setInterval回调
- pending callbacks:一些系统级的回调
- idle/prepare:libuv内部用
- poll:★ 最忙的岗亭 ★
- 等I/O完成(文件、网络等)
- 有完成事件就执行回调,没事就可能稍微等一等
- check:setImmediate回调
- 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最新规则)
- 当前同步代码执行完
- 执行所有 process.nextTick 队列(直到清空)
- 执行所有 Promise.then 微任务(直到清空)
- 进入下一轮事件循环:
- timers(setTimeout/setInterval)
- pending callbacks
- idle/prepare(内部)
- poll(I/O 回调最常在这里执行)
- check(setImmediate)
- close callbacks
- 每阶段结束后,再检查是否有新的微任务(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),不占主线程!