浏览器是怎么实现async/await的?

众所周知,JavaScript 是单线程语言,但是在实际业务场景中,却有大量的异步场景。在早期只能通过回调函数的方式处理异步操作,但回调函数的方式也出现了一些弊端,比如回调地狱,代码逻辑不连续等。后来社区经过探索推出了 Promise,使得处理异步逻辑变得线性,基本解决了回调函数的问题。接着又出现了基于 Generator 函数的异步方案,最后出现了 async/await,可以说它比较完美的解决了 JS 中异步操作问题。

async/await 从某种层面上来说是基于 Promise 的语法糖。其最大优点就是使得异步代码看起来像同步代码,让代码逻辑更加清晰。既然 async/await 是 Promise 的语法糖,那么是否能用 Promise 来实现 async/await ?解答这个问题之前,我们先来看一段代码:

function sleep(duration) {
    return new Promise(function(resolve) {
        setTimeout(resolve, duration);
    });
}

async function main() {
    console.log('sleep start');
    await sleep(1000);
    console.log('sleep end');
}

代码中,我们实现了一个 sleep 函数,它返回一个 Promise 。在 main 函数中,我们先打印一个 Log,然后在 await 关键词后面执行 sleep 函数,最后再打印一个 Log。main 函数运行的效果就是先打印一句 “sleep start” ,然后 1 秒后再打印 “sleep end” 。

await 有个关键的表现就是,它可以使当前函数中断执行,等到 await 后面的 Promise resolve后,才继续执行下面的语句。如果只用 Promise,是无法实现上面代码中 await 的中断效果的。在 JavaScript 中只有 Generator 函数可以实现函数中断效果。所以,使用 Generator + Promise,就可以实现 async/await。

以前的文章宏我们已经讲过浏览器中 Promise 的实现原理,其原理就是创建一个微任务。那么 Generator 函数中神奇的中断效果,浏览器是如何实现的,我们今天来探索一下。

Generator 函数

我们先来看看 Generator 函数:

function* genDemo() {
    console.log("开始执行第一段")
    yield 'generator 2'

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

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

    console.log("执行结束")
    return 'generator 2'
}

console.log('main 0')
let gen = genDemo()
console.log(gen.next().value)
console.log('main 1')
console.log(gen.next().value)
console.log('main 2')
console.log(gen.next().value)
console.log('main 3')
console.log(gen.next().value)
console.log('main 4')

Generator 函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号。不同的是,调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象(Iterator Object)。

下一步,必须调用遍历器对象的next方法,使得指针移向下一个状态。也就是说,每次调用next方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个yield表达式(或return语句)为止。换言之,Generator 函数是分段执行的,yield表达式是暂停执行的标记,而next方法可以恢复执行。

执行上面这段代码,观察输出结果,你会发现函数 genDemo 并不是一次执行完的,全局代码和 genDemo 函数交替执行。其实这就是生成器函数的特性,可以暂停执行,也可以恢复执行。

如此神奇的效果,相信你一定很好奇这其中的实现原理。那么接下来我们就来简单介绍下 JavaScript 引擎 V8 是如何实现一个函数的暂停和恢复的。

协程

要搞懂函数为何能暂停和恢复,那你首先要了解协程的概念。

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

正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。最重要的是,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。

为了让你更好地理解协程是怎么执行的,我结合上面那段代码的执行过程,画出了下面的“协程执行流程图”,你可以对照着代码来分析:

从图中可以看出来协程的四点规则:

  1. 通过调用生成器函数 genDemo 来创建一个协程 gen,创建之后,gen 协程并没有立即执行。
  2. 通过调用 gen.next,使 gen 协程执行。
  3. yield 关键字的作用是暂停 gen 协程的执行,并返回主要信息给父协程。
  4. return 关键字的作用是关闭当前协程,并将 return 后面的内容返回给父协程。

不过,对于上面这段代码,你可能又有这样疑问:父协程有自己的调用栈,gen 协程时也有自己的调用栈,当 gen 协程通过 yield 把控制权交给父协程时,V8 是如何切换到父协程的调用栈?当父协程通过 gen.next 恢复 gen 协程时,又是如何切换 gen 协程的调用栈?

要搞清楚上面的问题,你需要关注以下两点内容。

  • gen 协程和父协程是在主线程上交互执行的,并不是并发执行的,它们之前的切换是通过 yield 和 gen.next 来配合完成的。
  • 当在 gen 协程中调用了 yield 方法时,JavaScript 引擎会保存 gen 协程当前的调用栈信息,并恢复父协程的调用栈信息。同样,当在父协程中执行 gen.next 时,JavaScript 引擎会保存父协程的调用栈信息,并恢复 gen 协程的调用栈信息。

为了直观理解父协程和 gen 协程是如何切换调用栈的,你可以参考下图:

到这里相信你已经弄清楚了协程是怎么工作的,其实在 JavaScript 中,生成器就是协程的一种实现方式,这样相信你也就理解什么是生成器了。那么接下来,我们使用生成器和 Promise 来改造开头的那段 Promise 代码。改造后的代码如下所示:

//foo函数
function* foo() {
    let response1 = yield fetch('https://www.geekbang.org')
    console.log('response1')
    console.log(response1)
    let response2 = yield fetch('https://www.geekbang.org/test')
    console.log('response2')
    console.log(response2)
}

//执行foo函数的代码
let gen = foo()
function getGenPromise(gen) {
    return gen.next().value
}
getGenPromise(gen).then((response) => {
    console.log('response1')
    console.log(response)
    return getGenPromise(gen)
}).then((response) => {
    console.log('response2')
    console.log(response)
})

从图中可以看到,foo 函数是一个生成器函数,在 foo 函数里面实现了用同步代码形式来实现异步操作;但是在 foo 函数外部,我们还需要写一段执行 foo 函数的代码,如上述代码的后半部分所示,那下面我们就来分析下这段代码是如何工作的。

  • 首先执行的是let gen = foo(),创建了 gen 协程。
  • 然后在父协程中通过执行 gen.next 把主线程的控制权交给 gen 协程。
  • gen 协程获取到主线程的控制权后,就调用 fetch 函数创建了一个 Promise 对象 response1,然后通过 yield 暂停 gen 协程的执行,并将 response1 返回给父协程。
  • 父协程恢复执行后,调用 response1.then 方法等待请求结果。
  • 等通过 fetch 发起的请求完成之后,会调用 then 中的回调函数,then 中的回调函数拿到结果之后,通过调用 gen.next 放弃主线程的控制权,将控制权交 gen 协程继续执行下个请求。

以上就是协程和 Promise 相互配合执行的一个大致流程。不过通常,我们把执行生成器的代码封装成一个函数,并把这个执行生成器代码的函数称为执行器(可参考著名的 co 框架),如下面这种方式:

function* foo() {
    let response1 = yield fetch('https://www.geekbang.org')
    console.log('response1')
    console.log(response1)
    let response2 = yield fetch('https://www.geekbang.org/test')
    console.log('response2')
    console.log(response2)
}
co(foo());

通过使用生成器配合执行器,就能实现使用同步的方式写出异步代码了,这样也大大加强了代码的可读性。

async/await:异步编程的“终极”方案

由于生成器函数可以暂停,因此我们可以在生成器内部编写完整的异步逻辑代码,不过生成器依然需要使用额外的 co 函数来驱动生成器函数的执行,这一点非常不友好。

基于这个原因,ES7 引入了 async/await,这是 JavaScript 异步编程的一个重大改进,它改进了生成器的缺点,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力。你可以参考下面这段使用 async/await 改造后的代码:

async function getResult() {
    try {
        let id_res = await fetch(id_url)
        let id_text = await id_res.text()
        console.log(id_text)

        let new_name_url = name_url+"?id="+id_text
        console.log(new_name_url)

        let name_res = await fetch(new_name_url)
        let name_text = await name_res.text()
        console.log(name_text)
    } catch (err) {
        console.error(err)
    }
}
getResult()

观察上面这段代码,你会发现整个异步处理的逻辑都是使用同步代码的方式来实现的,而且还支持 try catch 来捕获异常,这就是完全在写同步代码,所以非常符合人的线性思维。

虽然这种方式看起来像是同步代码,但是实际上它又是异步执行的,也就是说,在执行到 await fetch 的时候,整个函数会暂停等待 fetch 的执行结果,等到函数返回时,再恢复该函数,然后继续往下执行。

其实 async/await 技术背后的秘密就是 Promise 和生成器应用,往底层说,就是微任务和协程应用。要搞清楚 async 和 await 的工作原理,我们就得对 async 和 await 分开分析。

我们先来看看 async 到底是什么。根据 MDN 定义,async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。

这里需要重点关注异步执行这个词,简单地理解,如果在 async 函数里面使用了 await,那么此时 async 函数就会暂停执行,并等待合适的时机来恢复执行,所以说 async 是一个异步执行的函数。

那么暂停之后,什么时机恢复 async 函数的执行呢?

要解释这个问题,我们先来看看,V8 是如何处理 await 后面的内容的。

通常,await 可以等待两种类型的表达式:

  • 可以是任何普通表达式。
  • 也可以是一个 Promise 对象的表达式。

如果 await 等待的是一个 Promise 对象,它就会暂停执行生成器函数,直到 Promise 对象的状态变成 resolve,才会恢复执行,然后得到 resolve 的值,作为 await 表达式的运算结果。

我们看下面这样一段代码:

function NeverResolvePromise(){
    return new Promise((resolve, reject) => {})
}
async function getResult() {
    let a = await NeverResolvePromise()
    console.log(a)
}
getResult()
console.log(0)

这一段代码,我们使用 await 等待一个没有 resolve 的 Promise,那么这也就意味着,getResult 函数会一直等待下去。

和生成器函数一样,使用了 async 声明的函数在执行时,也是一个单独的协程,我们可以使用 await 来暂停该协程,由于 await 等待的是一个 Promise 对象,我们可以 resolve 来恢复该协程。

下面是我从协程的视角,画的这段代码的执行流程图,你可以对照参考下:

如果 await 等待的对象已经变成了 resolve 状态,那么 V8 就会恢复该协程的执行,我们可以修改下上面的代码,来证明下这个过程:

function HaveResolvePromise(){
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(100)
          }, 0);
      })
}
async function getResult() {
    console.log(1)
    let a = await HaveResolvePromise()
    console.log(a)
    console.log(2)
}
console.log(0)
getResult()
console.log(3)

现在,这段代码的执行流程就非常清晰了,具体执行流程你可以参看下图:

如果 await 等待的是一个非 Promise 对象,比如 await 100,那么通用 V8 会隐式地将 await 后面的 100 包装成一个已经 resolve 的对象,其效果等价于下面这段代码:

function ResolvePromise(){
    return new Promise((resolve, reject) => {
            resolve(100)
      })
}
async function getResult() {
    let a = await ResolvePromise()
    console.log(a)
}
getResult()
console.log(3)

总结

Callback 模式的异步编程模型需要实现大量的回调函数,大量的回调函数会打乱代码的正常逻辑,使得代码变得不线性、不易阅读,这就是我们所说的回调地狱问题。

使用 Promise 能很好地解决回调地狱的问题,我们可以按照线性的思路来编写代码,这个过程是线性的,非常符合人的直觉。但是这种方式充满了 Promise 的 then() 方法,如果处理流程比较复杂的话,那么整段代码将充斥着大量的 then,语义化不明显,代码不能很好地表示执行流程。

我们想要通过线性的方式来编写异步代码,要实现这个理想,最关键的是要能实现函数暂停和恢复执行的功能。而生成器就可以实现函数暂停和恢复,我们可以在生成器中使用同步代码的逻辑来异步代码 (实现该逻辑的核心是协程),但是在生成器之外,我们还需要一个触发器来驱动生成器的执行,因此这依然不是我们最终想要的方案。

我们的最终方案就是 async/await,async 是一个可以暂停和恢复执行的函数,我们会在 async 函数内部使用 await 来暂停 async 函数的执行,await 等待的是一个 Promise 对象,如果 Promise 的状态变成 resolve 或者 reject,那么 async 函数会恢复执行。因此,使用 async/await 可以实现以同步的方式编写异步代码这一目标。

其实,上面讲的就是前段异步编程的方案史,如下图所示: