async/await 的真正原理:不仅仅是语法糖

380 阅读9分钟

1. 前言

相信在实际开发中,Promise大家一定用了不少,async/await也一定用过,但是大家对于它的认识大部分只停留在:用同步写异步?语法糖?

大厂面试越来越偏向问你原理,一个你觉得很简单的知识,一问原理可能就会回答的支支吾吾,所以本文将从大家最常见的async/await,来和大家好好聊聊它的原理。

2. async/await的实际使用例子

在真正说原理之前,我们还是需要带大家好好回顾一下async/await的使用:

场景:当我们有一个用于从服务器获取用户数据的异步函数,我需要先获取数据在进行处理,之后在输出结果,代码应该如何写呢?

Promise版本

function showUserInfo(userId) {
  return fetchUserData(userId)
    .then(function(user) {
      const info = `用户:${user.name},年龄:${user.age}`;
      console.log(info);
      return info;
    })
    .catch(function(err) {
      console.error("获取用户信息失败:", err);
      throw err;
    });
}

async/await版本:

// 一个模拟异步请求的函数,返回 Promise
function fetchUserData(userId) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ id: userId, name: "张三", age: 25 });
    }, 1000);
  });
}

// 实际使用 async/await
async function showUserInfo(userId) {
  try {
    // 等待异步请求完成
    const user = await fetchUserData(userId);
    // 处理数据
    const info = `用户:${user.name},年龄:${user.age}`;
    // 输出结果
    console.log(info);
    return info;
  } catch (err) {
    // 错误处理
    console.error("获取用户信息失败:", err);
    throw err;
  }
}

// 调用
showUserInfo(1001);

最后输出:用户:张三,年龄:25

我们通过这个小小的例子就可以发现,比起使用promise这种方式在函数里写异步方法,我们使用async/await这种方式将异步方法同步化。增加代码的可读性,整体看着也更加美观

3. async/await 的底层原理

关于babel语法降级的错误认知

首先大家要明确一个概念,不要将语法降级当作真正的原理。 现在大部分文章都是讲Babel如何将async/await转换成generator + Promise的兼容代码。将这一串兼容代码当作其真正的原理。实际上这是错误的认知。

我们可以先来看看使用generator + Promise的兼容代码是如何实现async/await的语法降级兼容低版本浏览器的

Generator函数的使用

什么是Generator

Generator(生成器)是ES6引入的一种可以中断和恢复执行的函数。

语法:

用functon*定义

内部用yield关键字“暂停函数的执行”

function* gen() {
  yield 1;
  yield 2;
  yield 3;
}
  • fuction* 声明生成器函数
  • yield 用于暂停函数的执行,并在后续语句产出值
const g = gen(); // g 是一个生成器对象

在使用时我们需要定义gen()创建生成器对象

console.log(g.next()); // { value: 1, done: false }
console.log(g.next()); // { value: 2, done: false }
console.log(g.next()); // { value: 3, done: false }
console.log(g.next()); // { value: undefined, done: true }

我们通过next()方法,每次调用其,函数就会从上一个yield处恢复执行直到下一个yield或函数结束。

value是产出的值,done表示是否执行完毕

yield的用处

  • yiled可以暂停函数的执行,并返回一个值
  • 下次调用next()时,可以从暂停处继续往下执行

我们的next()方法也能够传参

  • 第一次调用 next() 时,传参无效(会被忽略),因为此时 Generator 还没开始执行,参数无法传递到第一个 yield。

  • 从第二次 next(value) 开始,传入的参数会作为上一个 yield 表达式的返回值,赋值给左侧变量。

function* gen() {
  const a = yield 1;
  console.log('a:', a);
  const b = yield 2;
  console.log('b:', b);
}

const g = gen();
console.log(g.next());      // { value: 1, done: false }
console.log(g.next('foo')); // a: foo  { value: 2, done: false }
console.log(g.next('bar')); // b: bar  { value: undefined, done: true }
  • 第一次 next(),yield 1,暂停,返回 1
  • 第二次 next('foo'),'foo' 作为上一个 yield 的返回值,赋给 a
  • 第三次 next('bar'),'bar' 作为上一个 yield 的返回值,赋给 b

我们讲完了Generator的基本用法后,我们如何使用Generator + Promise将async进行语法降级呢?

async/await 降级为 Generator 时的流程

Babel 等工具在将 async/await 降级为 generator + Promise 时,会用到 next 传参机制

  • 每次 yield 一个 Promise,外部用 then 拿到结果后,通过 next(result) 把结果传回 generator 内部,赋值给 await 左侧的变量。
  • 这样 generator 内部的变量赋值流程控制就和 async/await 的语义一致了。

这里写伪代码示例

假设这里有async代码;

async function foo() {
  const a = await step1();
  const b = await step2(a);
  return b;
}

降级为generator:

function* foo() {
  const a = yield step1();
  const b = yield step2(a);
  return b;
}

降级之后需要使用promise进行自动化调用:

const gen = foo();
gen.next().value.then(res1 => {
  gen.next(res1).value.then(res2 => {
    gen.next(res2);
  });
});
  • 第一次 gen.next(),yield step1(),外部 then 拿到结果 res1
  • 第二次 gen.next(res1),res1 作为 await step1() 的结果,赋值给 a
  • 依此类推

当然这样写需要手动 next、手动 then,嵌套多了很难维护。所以我们就封装一个高阶函数,自动驱动generator,返回一个promise:

function autoRun(generatorFn) {
  return function(...args) {
    return new Promise((resolve, reject) => {
      const gen = generatorFn(...args);

      function step(nextF, arg) {
        let next;
        try {
          next = nextF.call(gen, arg);
        } catch (e) {
          return reject(e);
        }
        if (next.done) {
          return resolve(next.value);
        }
        // next.value 应该是 Promise
        Promise.resolve(next.value).then(
          v => step(gen.next, v),
          e => step(gen.throw, e)
        );
      }

      step(gen.next);
    });
  };
}

当我们的generator函数为

function* foo() {
  const res1 = yield p(1);
  const res2 = yield p(res1);
  return res2;
}

我们就可以这样进行调用

const asyncFoo = autoRun(foo);

asyncFoo().then(result => {
  console.log('最终结果:', result);
});

通过上面的一些讲解,可能会给大家产生一些错觉:这个好像就是原理啊。都拆的这么细了。其实不然。

Generator + Promise + 自动驱动,只是 async/await 的“兼容实现”或“语法降级方案”,并不是 async/await 的底层原理。

这些方案的本质,是为了让不支持 async/await 的老环境也能用上类似的语法和功能。它们通过 Generator 的暂停-恢复机制模拟了 async/await 的行为,但这只是“表象”。

真正的 async/await 原理,其实藏在 JavaScript 引擎(如 V8)的底层实现中。

  • async/await 在引擎内部会被编译成状态机,每个 await 都是一个 “暂停点”
  • 遇到 await 时,执行帧(变量、状态、作用域等)会被保存到堆上,Promise 完成后再恢复执行。
  • 整个过程由引擎自动完成,性能和调试体验都远超语法降级方案。

那么,async/await 的底层原理到底是什么?我们接下来就走进 V8 引擎,看看 async/await 在底层是如何被实现的。

async/await在V8引擎的底层原理:

状态机原理

async/await 在引擎内部会被编译成状态机,每个 await 都是一个“暂停点”

  • 当你写下一个async函数后,v8会在编译阶段把它拆解成多个“状态”
  • 每遇到一个await,就会生成一个 “暂停点”,把函数分割成多个状态
  • 这些状态的切换由Promise的完成(resolve/reject)来驱动

伪代码示意:

async function foo() {
  const a = await step1();
  const b = await step2(a);
  return b;
}

V8 会把它拆成类似这样的状态机:

switch (state) {
  case 0:
    promise1 = step1();
    state = 1;
    return promise1.then(res => {
      a = res;
      runStateMachine();// 这里恢复状态机,继续执行 case 1
    });
  case 1:
    promise2 = step2(a);
    state = 2;
    return promise2.then(res => {
      b = res;
      runStateMachine();
    });
  case 2:
    return b;//这里恢复状态机,继续执行 case 2
}

runStateMachine 的含义

  • 本质上,它代表“恢复 async 函数的执行”。
  • 当某个 await 后面的 Promise 完成时,状态机会切换到下一个状态,继续执行后续代码。

伪代码的其他部分

  • 每个 await 都是一个状态的分界点。
  • 状态机会自动切换,直到所有 await 执行完毕。
  • 每次 Promise 完成后,runStateMachine() 就会让状态机跳到下一个 case,继续执行 async 函数剩下的逻辑。

关于执行帧的保存与恢复

执行帧: async函数暂停时的快照,表示当前暂停的状态下,所有的局部变量,作用域链,异常处理信息等。

当函数的执行遇到async后,执行帧会被保存到堆上,Promise完成后再恢复执行。

为什么在堆上

因为 await 之后的代码会在未来的某个时刻(Promise 完成后)才恢复执行,这时原来的调用栈早已清空,只有堆上的快照能保证上下文不丢失。

保存与恢复流程

1.执行到 await,V8 把当前执行帧序列化,存到堆上

2.Promise 完成后,V8 从堆上反序列化出执行帧,恢复所有变量和状态,继续执行下一个状态。

3.这样即使 async 函数多次暂停和恢复,所有上下文都不会丢失

举个小🌰

async function foo() {
  let a = 1;
  await bar();
  let b = 2;
  await baz();
  return a + b;
}
  • 第一次遇到 await bar(),保存 a=1、状态=1 到堆上。
  • bar() 完成后,恢复帧,执行 let b=2,再遇到 await baz(),保存 a=1、b=2、状态=2 到堆上。
  • baz() 完成后,恢复帧,执行 return a+b。

微任务队列调度

await后续代码执行时机: await后续代码不会很快执行,而是被引擎打包成一个微任务,放入微任务队列,再经历一次事件循环,才能够执行。

异常处理

如果 await 的 Promise 被 reject,异常会被抛回到 async 函数暂停点,V8 会查找最近的 catch 块,跳转到 catch 代码块继续执行。

如果没有 catch,async 函数返回的 Promise 会变为 rejected,外部可以用 .catch 捕获。

图解一手总体流程:

image.png

4.总结

1.我们在学习原理时,不应该是为了兼容到低版本浏览器而语法降级的代码视作原理。比如async是10。使用Generator + promise这个方法就相当于 2 + 8。诚然2+8确实是10,Generator + promise也确实能实现,但不能说2 + 8就是原理。所以我们在分析原理时就需要从引擎角度去看待问题。

2.async/await 在引擎内部会被编译成状态机,每个 await 都是一个暂停点,执行帧会被保存到堆上,Promise 完成后再恢复执行,整个过程由引擎自动调度和优化,性能和调试体验都远超语法降级方案。


参考资料: