我不知道的 V8(16)— async/await:状态机、伪协程与微任务调度
await 让函数"暂停",等 Promise 完成后"继续"——这个描述没错,但暂停是什么意思?主线程真的停下来等了吗?V8 用什么机制实现了"看似同步、实为异步"的执行模式?答案在 V8 把 async 函数转换成的状态机里,以及await 背后的微任务调度机制里。
await 让函数”暂停”,等 Promise 完成后”继续”——这个描述没错,但暂停是什么意思?主线程真的停下来等了吗?V8 用什么机制实现了”看似同步、实为异步”的执行模式?答案在 V8 把 async 函数转换成的状态机里,以及await 背后的微任务调度机制里。
一、async 函数的本质:Promise + 状态机
V8 把 async 函数编译成两个部分:外层是一个 Promise 包装,内部是一个状态机。
原始的 async 函数:
async function fetchData() {
const result = await Promise.resolve(42);
console.log(result);
return result;
}
V8 内部转换后的等效逻辑(概念性,非实际代码):
function fetchData() {
// 外层:整个函数返回一个 Promise
return new Promise((resolve, reject) => {
let state = 0;
let value;
// 内部状态机:每个 await 是一个状态点
function resume(result) {
value = result;
try {
switch (state) {
case 0:
// await 之前的代码
state = 1;
// await Promise.resolve(42)
// 把"继续执行"挂到 Promise 的 then 回调上
return Promise.resolve(42).then(resume);
case 1:
// await 之后的代码
console.log(value); // 42
resolve(value); // 整个 async 函数完成
break;
}
} catch (e) {
reject(e);
}
}
resume(); // 启动
});
}
每个 await 把函数切成了两段:
await之前:在当前状态执行await之后:挂到 Promise 的微任务回调里,等待下一次resume
二、await 不是”暂停主线程”
理解这一点对于真正搞清楚 async/await 的行为很关键:
async function test() {
console.log('Before await');
await Promise.resolve();
console.log('After await'); // 这行在微任务里执行
}
test();
console.log('After test()');
// 输出:
// Before await
// After test() ← 不是 "After await"!
// After await
执行过程:
test()被调用,执行到await,状态机切到 state 1,把After await那段代码挂到Promise.resolve()的then回调(微任务)里test()“返回”(实际上返回了它的内部 Promise),主线程继续console.log('After test()')执行- 当前宏任务(同步代码)结束,微任务队列清空
After await的微任务执行
“暂停”只是状态机的挂起,主线程从未停止。 这是 async/await 和真正的协程(如 Go goroutine)的根本区别——V8 的实现是单线程的,没有真正的并行,只有通过事件循环的异步调度。
三、V8 的字节码层面
V8 为 await 生成专用字节码指令 Await。执行到 Await 时:
- V8 创建
PromiseReaction对象,存储当前执行位置(Continuation)和上下文 - 将 Continuation 打包成微任务,注册到等待的 Promise 上
- 当前函数的栈帧被”挂起”(实际上是弹出调用栈,但状态保存在闭包里)
- 主线程继续执行后续代码
Promise 解析后,V8 调用 ResumeGenerator 字节码:
- 从
PromiseReaction里取出 Continuation - 恢复上下文(变量状态、执行位置)
- 从
await的下一行继续执行
四、多个 await 的执行顺序
async function multiAwait() {
console.log('1');
await step1(); // 微任务 1
console.log('3');
await step2(); // 微任务 2
console.log('5');
}
multiAwait();
console.log('2');
// 输出:1 → 2 → 3(微任务 1 后)→ 4(step2 解析后微任务中)→ 5
每个 await 都是一次微任务调度点。如果 step1() 和 step2() 相互独立,可以并行:
// 串行(效率低):总时间 = step1 + step2
async function serial() {
const r1 = await step1(); // 等 step1 完成
const r2 = await step2(); // 再等 step2 完成
return [r1, r2];
}
// 并行(效率高):总时间 = max(step1, step2)
async function parallel() {
const [r1, r2] = await Promise.all([step1(), step2()]);
// 两个 Promise 同时开始,等两者都完成
return [r1, r2];
}
这是 async/await 最常见的性能优化点:把没有依赖关系的 await 改为 Promise.all。
五、错误处理的工作机制
async 函数的错误处理通过 Promise 的 reject 机制传递:
async function mightFail() {
throw new Error('something went wrong');
// 等价于:return Promise.reject(new Error("..."))
}
// 方式一:try-catch(同步风格)
async function handler() {
try {
await mightFail();
} catch (e) {
console.log('caught:', e.message);
}
}
// 方式二:.catch()(Promise 风格)
mightFail().catch((e) => console.log('caught:', e.message));
状态机中,V8 在 switch 外面包了 try-catch(见第一节的等效代码),任何阶段抛出的错误都会被捕获并传递给外层 Promise 的 reject。
六、async/await 对调用栈的影响
await 会让调用栈出现一个”断层”——await 之后的代码在新的微任务里执行,不在原来的调用栈帧里:
async function a() {
await b();
doSomething(); // 这行执行时,a() 的原始调用栈已经不在了
}
这影响错误追踪:抛出错误时,调用栈里可能看不到完整的调用链。Chrome DevTools 提供了 “Async Call Stack” 功能(在 Sources 面板启用),可以重建跨 await 的完整调用链,便于调试。
# Node.js 中查看异步调用栈
node --async-stack-traces your-script.js
七、总结
async/await 在 V8 内部的实现是 Promise + 状态机:
- async 函数被编译成一个返回 Promise 的函数,内部维护一个状态机
- 每个
await是一个状态切换点,把后续代码挂成微任务,主线程继续执行 await的”暂停” 是状态机挂起,不是主线程阻塞;恢复通过微任务队列触发
理解这套机制,能清楚解释:为什么 await 后的代码比紧随其后的同步代码晚执行、为什么串行 await 比 Promise.all 慢、为什么调试 async 函数时调用栈有断层。
本系列其他文章: