async 和 await 是怎么工作的?——你不知道的生成器与协程

2,915 阅读4分钟

什么是生成器函数?

生成器函数是一个带星号的函数,可以暂停执行与恢复执行。 async/await 使用了 协程(Generator) 和 微任务(Promise) 两种技术来实现。

function* genDemo() {
  console.log("开始执行第 1 段");
  yield "generator 1";

  console.log("开始执行第 2 段");
  yield "generator 2";

  console.log("执行结束");
  return "generator 3";
}

console.log("main 0");
let gen = genDemo(); // 此处不会打印"开始执行第 1 段"只有在执行 gen.next 时才会执行
console.log(gen.next().value); // 开始执行第 1 段  generator 1
console.log("main 1");
console.log(gen.next().value);
console.log("main 2");
console.log(gen.next().value);
console.log("main 3");
  • 输出内容如下 main 0
    开始执行第 1 段
    generator 1
    main 1
    开始执行第 2 段
    generator 2
    main 2
    执行结束
    generator 3
    main 3

从上面输出结果可以看出,生成器函数与主函数是交替执行的。
生成器函数中遇到 yield 关键字时,就会返回 yield 后的内容给外部并把执行权交给外部函数去执行。
外部函数又可以通过 gen.next 恢复生成器函数的执行。

什么是协程?

协程是一种比线程更加轻量级的存在,可以看成是跑在线程上的任务。就像一个进程可以有多个线程一样,一个线程也可以有多个协程。
但是,线程上同时只能执行一个协程。比如:当前执行的是 A 协程,要启动 B,就需要将主线程的控制权交给 B 协程;A 暂停执行,B 恢复执行。通常,如果从 A 协程启动 B 协程,我们就把 A 协程称为 B 协程的父协程。

协程执行流程图

从图中可以看出:

  1. 通过生成器函数 genDemo 创建的协程 gen 创建之后并没有立即执行。
  2. 通过调用 gen.next 可以使协程执行。
  3. 通过 yield 关键字来暂停 gen 协程的执行,并返回主要信息给父协程。
  4. 若在执行期间遇到 return,JS 引擎会结束当前协程并将 return 后的内容返回给父协程。
  • 父协程与 gen 协程都有自己的调用栈,当控制权通过 yield 与 gen.next 互相切换时,V8 是如何切换调用栈的?
  1. gen 协程与父协程是在主线程上交互执行的,并不是并发执行的,它们之间的切换是通过 yield 与 gen.next 配合完成。
  2. gen 中调用 yield 时,JS 引擎会保存 gen 协程当前的调用栈信息并恢复父协程的调用栈信息。同理,父协程中执行 gen.next 时,JS 引擎会保存父协程调用栈信息并恢复 gen 协程的调用栈信息。如下图:
    协程间的切换

async/await

async 是什么?

async 是一个通过异步执行隐式返回 Promise作为结果的函数。

  • 隐式返回 Promise
async function foo() {
  return 2;
}
foo(); // Promise {<resolved>: 2}

await 是什么?

观察下面代码的输出:

async function foo() {
  console.log(1);
  let a = await 100;
  console.log(a);
  console.log(2);
}
console.log(0);
foo();
console.log(3);

输出:0 1 3 100 2 执行流程图如下:

async、await执行流程图

当执行到 await 100 时,会创建一个 Promise 对象,如下:

let promise_ = new Promise((resolve, reject) => {
  resolve(100);
});

JS 引擎会将该任务提交到微任务队列,然后暂停当前协程的执行,将主线程的控制权转交给父协程执行,同时将 promise_ 对象返回给父协程(如下)。

async function foo() {
  ...
  let a = await 100
  ...
}
console.log(foo())
//Promise {<pending>}__proto__: ... "
// [[PromiseStatus]]: "resolved"
// [[PromiseValue]]: undefined

主线程控制权交给父协程后,父协程调用 promise_.then 来监控 promise 状态的改变。

接下来执行父协程的流程,打印出 3。随后父协程将执行结束,在结束前,进入微任务的检查点去执行微任务队列,微任务队列中有 resolve(100) 等待执行,执行到这里时,会触发 promise_.then 中的回调函数,如下:

promise_.then(value => {
  // 回调函数触发后,将主线程的控制权交给 foo 协程,并将 value 传给协程
});

foo 协程激活后,将 value 的值给了变量 a,然后继续执行后面语句,执行完成,将控制权归还给父协程。

思考题

async function foo() {
  console.log("foo");
}
async function bar() {
  console.log("bar start");
  await foo();
  console.log("bar end");
}
console.log("script start");
setTimeout(() => {
  console.log("setTimeout");
}, 0);
bar();
new Promise(resolve => {
  console.log("promise executor");
  resolve();
}).then(() => {
  console.log("promise then");
});
console.log("script end");

输出如下:
scritp start
bar start foo
promise executor
script end
bar end
promise then
setTimeout

注意点: 第三步会输出 foo,而不是 promise executor. 因为 await 是将 return 的值用 resolve 包装提交到微任务队列,console.log 语句不受影响,可以直接输出。

setTimeout 被放到延迟队列中,而不是下一轮宏任务。
本轮宏任务执行完成后,会执行延迟队列中的任务。
宏任务中父协程执行结束前,会去微任务队列检查执行微任务。

参考资料

浏览器工作原理与实践