10. 事件循环、微任务与其他设计
事件循环不是 V8 单独实现的。V8 提供微任务队列能力,浏览器或 Node 负责把它接入自己的事件循环。
1. 定位:引擎能力与宿主调度
从架构边界看,事件循环要拆成两层:
| 层级 | 负责内容 | 典型实现 |
|---|---|---|
| JS 引擎 | 执行 JS、维护调用栈、提供 Promise 微任务能力 | V8 |
| 宿主环境 | 注册异步 API、维护任务队列、决定回调何时进入 JS | 浏览器、Node.js |
所以浏览器和 Node 都能运行 JavaScript,但事件循环语义并不完全一样。setTimeout、DOM 事件、网络回调、文件 I/O、setImmediate 都不是 V8 自己调度的,而是宿主把回调交给 V8 执行。
2. 微任务策略
V8 提供几种微任务执行策略:
kExplicit:宿主显式调用时才执行微任务。kScoped:由作用域控制执行时机。kAuto:当一次 JS 调用结束、调用深度归零时自动执行。
浏览器通常更接近 kAuto 的语义:一段 JS 执行完后,清空微任务,再进入后续渲染机会。
3. 浏览器:宏任务、微任务、渲染
这样描述顺序:
- 执行一个宏任务中的同步 JS。
- 当前调用栈清空。
- 执行微任务队列,例如
Promise.then。 - 浏览器有机会进行渲染。
- 进入下一个宏任务。
注意:微任务会在下一个宏任务之前执行,过多微任务可能阻塞渲染。
4. Node.js:libuv 事件循环阶段
Node.js 采用异步 I/O 模型:同步代码在主线程执行,文件、网络等异步操作交给底层系统能力和 libuv 处理,回调再回到 JS 主线程执行。事件循环负责决定这些回调什么时候被取出执行。
Node 的一次事件循环可以简化为六个阶段:
| 阶段 | 主要处理内容 | 常见来源 |
|---|---|---|
timers | 到期的定时器回调 | setTimeout、setInterval |
pending callbacks | 推迟到下一轮执行的系统回调 | 部分 TCP / 系统错误回调 |
idle, prepare | Node / libuv 内部使用 | 内部调度 |
poll | I/O 回调,必要时等待新的 I/O | fs.readFile、网络 I/O |
check | check 队列回调 | setImmediate |
close callbacks | 关闭事件回调 | socket close 等 |
这里的 pending callbacks 不是 Promise 的 pending 状态,而是 libuv 的一个阶段:有些系统级 I/O 回调不会在产生它们的那一轮立刻执行,会被延后到下一轮事件循环的 pending callbacks 阶段处理。常见例子是某些 TCP 错误回调,例如连接被拒绝后的 ECONNREFUSED。日常业务代码一般不会主动“投递一个 pending callbacks 任务”,更多是知道它存在,避免把所有 I/O 回调都简单归到 poll。
可以用仓库里的脚本观察这些阶段:
node scripts/node-event-loop-phases.cjs这个脚本会打印同步代码、微任务、timers、poll、check、close callbacks 的执行日志。idle, prepare 是 libuv 内部阶段,用户 JS 不能直接注册回调;pending callbacks 依赖具体系统回调,脚本用 TCP 连接错误做一个可观察示例。
心智模型:
同步 JS 执行完
-> timers
-> pending callbacks
-> idle / prepare
-> poll
-> check
-> close callbacks
-> 下一轮但这个图只描述宏任务阶段,不能忽略微任务。Node 会在合适的阶段边界清空微任务队列,因此微任务会穿插在这些阶段之间。
5. Node.js 的宏任务与微任务
Node 中常见宏任务:
| 类型 | 示例 | 所在阶段 |
|---|---|---|
| 定时器 | setTimeout、setInterval | timers |
| I/O | fs.readFile、网络请求回调 | poll |
| 立即任务 | setImmediate | check |
| 关闭回调 | socket / handle close 回调 | close callbacks |
Node 中常见微任务:
| 类型 | 示例 | 优先级 |
|---|---|---|
| nextTick 队列 | process.nextTick | 高于 Promise 微任务 |
| Promise 微任务 | Promise.then/catch/finally、queueMicrotask | 普通微任务 |
核心顺序可以这样记:
- 主线程同步代码先执行。
- 同步代码结束后,先处理微任务,再进入事件循环阶段。
- 每个阶段的回调执行后,进入下一个阶段前会检查并清空微任务。
process.nextTick优先于 Promise 微任务。- 微任务里继续产生微任务,会继续清空,可能推迟后续宏任务。
6. setTimeout 与 setImmediate 的顺序
setTimeout(fn, 0) 不保证一定早于 setImmediate(fn)。
在主模块里直接写:
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));两者顺序可能受启动开销、定时器是否已经到期、事件循环何时进入各阶段影响。虽然 timers 阶段排在 check 阶段前,但如果进入事件循环时 0ms 定时器还没有准备好,本轮可能先走到 check,于是 setImmediate 先执行。
如果两者写在 I/O 回调里,setImmediate 通常会先执行:
fs.readFile("./demo.txt", () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
});原因是 I/O 回调发生在 poll 阶段,当前轮接下来会进入 check 阶段,所以 setImmediate 会先于下一轮 timers 阶段的定时器。
7. 易错点
| 说法 | 更准确的理解 |
|---|---|
| “事件循环是 V8 实现的” | V8 提供执行与微任务能力,事件循环由浏览器或 Node 等宿主实现 |
| “宏任务一定等整轮结束后才跑微任务” | Node 会在阶段边界处理微任务,微任务会穿插在阶段之间 |
“setTimeout(0) 一定早于 setImmediate” | 主模块中不稳定;I/O 回调中通常 setImmediate 更早 |
“process.nextTick 和 Promise 同级” | Node 中 process.nextTick 优先级更高,滥用会饿死 I/O |
| “异步 API 的回调在后台线程执行” | 异步操作可能在底层线程/系统能力中完成,但 JS 回调仍回到主线程执行 |
8. V8 和事件循环的边界
宿主决定什么时候把回调交给 V8 执行。V8 负责的是:执行 JS、管理微任务队列、在合适的策略下触发微任务 checkpoint。
这个边界也解释了为什么同样是 JavaScript,浏览器与 Node 的执行顺序细节会不同:V8 相同,不代表宿主调度模型相同。
9. Torque 与 builtins
V8 很多内建函数不是简单手写 C++。Torque / CodeStubAssembler 用更接近引擎内部的方式描述 builtins,再生成代码。
这样做是为了兼顾性能、类型安全、GC 协作和可维护性。
10. WASM
WebAssembly 与 JS 共享 V8 的部分基础设施,例如 Isolate、代码管理和运行时支持,但它有独立的类型系统、编译路径和调用约定。
11. 参考资料
12. 总结
事件循环由宿主负责,V8 负责执行 JS 和微任务机制。浏览器里常见顺序是:同步 JS → 微任务 → 渲染机会 → 下一个任务。Node.js 则围绕 libuv 的 timers -> pending callbacks -> idle/prepare -> poll -> check -> close callbacks 阶段推进,并在阶段边界处理微任务;其中 process.nextTick 优先于 Promise 微任务,setTimeout(0) 与 setImmediate 的先后要结合所处阶段判断。