Skip to content
目录

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. 浏览器:宏任务、微任务、渲染

这样描述顺序:

  1. 执行一个宏任务中的同步 JS。
  2. 当前调用栈清空。
  3. 执行微任务队列,例如 Promise.then
  4. 浏览器有机会进行渲染。
  5. 进入下一个宏任务。

注意:微任务会在下一个宏任务之前执行,过多微任务可能阻塞渲染。

4. Node.js:libuv 事件循环阶段

Node.js 采用异步 I/O 模型:同步代码在主线程执行,文件、网络等异步操作交给底层系统能力和 libuv 处理,回调再回到 JS 主线程执行。事件循环负责决定这些回调什么时候被取出执行。

Node 的一次事件循环可以简化为六个阶段:

阶段主要处理内容常见来源
timers到期的定时器回调setTimeoutsetInterval
pending callbacks推迟到下一轮执行的系统回调部分 TCP / 系统错误回调
idle, prepareNode / libuv 内部使用内部调度
pollI/O 回调,必要时等待新的 I/Ofs.readFile、网络 I/O
checkcheck 队列回调setImmediate
close callbacks关闭事件回调socket close

这里的 pending callbacks 不是 Promise 的 pending 状态,而是 libuv 的一个阶段:有些系统级 I/O 回调不会在产生它们的那一轮立刻执行,会被延后到下一轮事件循环的 pending callbacks 阶段处理。常见例子是某些 TCP 错误回调,例如连接被拒绝后的 ECONNREFUSED。日常业务代码一般不会主动“投递一个 pending callbacks 任务”,更多是知道它存在,避免把所有 I/O 回调都简单归到 poll

可以用仓库里的脚本观察这些阶段:

bash
node scripts/node-event-loop-phases.cjs

这个脚本会打印同步代码、微任务、timerspollcheckclose callbacks 的执行日志。idle, prepare 是 libuv 内部阶段,用户 JS 不能直接注册回调;pending callbacks 依赖具体系统回调,脚本用 TCP 连接错误做一个可观察示例。

心智模型:

txt
同步 JS 执行完
	-> timers
	-> pending callbacks
	-> idle / prepare
	-> poll
	-> check
	-> close callbacks
	-> 下一轮

但这个图只描述宏任务阶段,不能忽略微任务。Node 会在合适的阶段边界清空微任务队列,因此微任务会穿插在这些阶段之间。

5. Node.js 的宏任务与微任务

Node 中常见宏任务:

类型示例所在阶段
定时器setTimeoutsetIntervaltimers
I/Ofs.readFile、网络请求回调poll
立即任务setImmediatecheck
关闭回调socket / handle close 回调close callbacks

Node 中常见微任务:

类型示例优先级
nextTick 队列process.nextTick高于 Promise 微任务
Promise 微任务Promise.then/catch/finallyqueueMicrotask普通微任务

核心顺序可以这样记:

  1. 主线程同步代码先执行。
  2. 同步代码结束后,先处理微任务,再进入事件循环阶段。
  3. 每个阶段的回调执行后,进入下一个阶段前会检查并清空微任务。
  4. process.nextTick 优先于 Promise 微任务。
  5. 微任务里继续产生微任务,会继续清空,可能推迟后续宏任务。

6. setTimeoutsetImmediate 的顺序

setTimeout(fn, 0) 不保证一定早于 setImmediate(fn)

在主模块里直接写:

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

两者顺序可能受启动开销、定时器是否已经到期、事件循环何时进入各阶段影响。虽然 timers 阶段排在 check 阶段前,但如果进入事件循环时 0ms 定时器还没有准备好,本轮可能先走到 check,于是 setImmediate 先执行。

如果两者写在 I/O 回调里,setImmediate 通常会先执行:

js
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 的先后要结合所处阶段判断。

MIT License.