• async await使用同步方式写异步代码


    在没有async await的时代,我们会用Promise 实现回调操作,使用 Promise 能很好地解决回调地狱的问题,但是这种方式充满了 Promise 的 then() 方法,如果处理流程比较复杂的话,那么整段代码将充斥着 then,语义化不明显,代码不能很好地表示执行流程。

    比如下面这样一个实际的使用场景:先请求内容,等返回信息之后,再请求另外一个资源。下面代码展示的是使用 fetch 来实现这样的需求,fetch 被定义在 window 对象中,可以用它来发起对远程资源的请求,该方法返回的是一个 Promise 对象,这和 XFetch 很像,只不过 fetch 是浏览器原生支持的,并有没利用 XMLHttpRequest 来封装。

    fetch('https://www.modb.pro')
      .then((response) => {
          console.log(response)
          return fetch('https://www.modb.pro/test')
      }).then((response) => {
          console.log(response)
      }).catch((error) => {
          console.log(error)
      })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    从这段 Promise 代码可以看出来,使用 promise.then 也是相当复杂,虽然整个请求流程已经线性化了,但是代码里面包含了大量的 then 函数,使得代码依然不是太容易阅读。基于这个原因,ES7 引入了 async/await,这是 JavaScript 异步编程的一个重大改进,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力,并且使得代码逻辑更加清晰:

    async function foo(){
      try{
        let response1 = await fetch('https://www.modb.pro')
        console.log('response1')
        console.log(response1)
        let response2 = await fetch('https://www.modb.pro/test')
        console.log('response2')
        console.log(response2)
      }catch(err) {
           console.error(err)
      }
    }
    foo()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    通过上面代码,发现整个异步处理的逻辑都是使用同步代码的方式来实现的,而且还支持 try catch 来捕获异常,这就是完全在写同步代码,所以是非常符合人的线性思维的。但是很多人都习惯了异步回调的编程思维,对于这种采用同步代码实现异步逻辑的方式,还需要一个转换的过程,因为这中间隐藏了一些容易让人迷惑的细节。

    来看看 JavaScript 引擎是如何实现 async/await 的。

    首先需要知道生成器(Generator)是如何工作的,接着需要知道 Generator 的底层实现机制——协程(Coroutine);又因为 async/await 使用了 Generator 和 Promise 两种技术,所以紧接着就通过 Generator 和 Promise 来分析 async/await 到底是如何以同步的方式来编写异步代码的。

    生成器 VS 协程

    什么是生成器函数?

    生成器函数是一个带星号函数,而且是可以暂停执行和恢复执行的:

    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')
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

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

    • 在生成器函数内部执行一段代码,如果遇到 yield 关键字,那么 JavaScript 引擎将返回关键字后面的内容给外部,并暂停该函数的执行。
    • 外部函数可以通过 next 方法恢复函数的执行

    关于函数的暂停和恢复,这其中的原理,就先说明一下 JavaScript 引擎 V8 是如何实现一个函数的暂停和恢复的,这也会有助于理解后面要介绍的 async/await。

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

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

    为了更好地理解协程是怎么执行的,可以看看下面的“协程执行流程图”:

    image.png

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

    • 通过调用生成器函数 genDemo 来创建一个协程 gen,创建之后,gen 协程并没有立即执行。
    • 要让 gen 协程执行,需要通过调用 gen.next。
    • 当协程正在执行的时候,可以通过 yield 关键字来暂停 gen 协程的执行,并返回主要信息给父协程。
    • 如果协程在执行期间,遇到了 return 关键字,那么 JavaScript 引擎会结束当前协程,并将 return 后面的内容返回给父协程。

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

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

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

    直观理解父协程和 gen 协程是如何切换调用栈的:

    image.png

    其实在 JavaScript 中,生成器就是协程的一种实现方式。使用生成器和 Promise 来改造开头的那段 Promise 代码:

    //foo 函数
    function* foo() {
        let response1 = yield fetch('https://www.modb.pro')
        console.log('response1')
        console.log(response1)
        let response2 = yield fetch('https://www.modb.pro/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)
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    从图中可以看到,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.modb.pro')
        console.log('response1')
        console.log(response1)
        let response2 = yield fetch('https://www.modb.pro/test')
        console.log('response2')
        console.log(response2)
    }
    co(foo());
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

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

    async/await

    虽然生成器已经能很好地满足我们的需求了,但是程序员的追求是无止境的,这不又在 ES7 中引入了 async/await,这种方式能够彻底告别执行器和生成器,实现更加直观简洁的代码。其实 async/await 技术背后的秘密就是 Promise 和生成器应用,往低层说就是微任务和协程应用。要搞清楚 async 和 await 的工作原理,我们就得对 async 和 await 分开分析。

    1. async

    根据 MDN 定义,async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。

    对 async 函数的理解,这里需要重点关注两个词:异步执行和隐式返回 Promise。

    先来看看是如何隐式返回 Promise 的:

    async function foo() {
        return 2
    }
    
    console.log(foo())  // Promise {: 2}
    
    • 1
    • 2
    • 3
    • 4
    • 5

    执行这段代码,可以看到调用 async 声明的 foo 函数返回了一个 Promise 对象,状态是 resolved,返回结果如下所示:

    Promise {<resolved>: 2}
    
    • 1

    2. await

    知道了 async 函数返回的是一个 Promise 对象,那再来看看 await 到底是什么。

    async function foo() {
        console.log(1)
        let a = await 100
        console.log(a)
        console.log(2)
    }
    console.log(0)
    foo()
    console.log(3)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    观察上面这段代码,站在协程的视角来看看这段代码的整体执行流程图:

    image.png

    结合上图,来一起分析下 async/await 的执行流程。

    首先,执行console.log(0)这个语句,打印出来 0。

    紧接着就是执行 foo 函数,由于 foo 函数是被 async 标记过的,所以当进入该函数的时候,JavaScript 引擎会保存当前的调用栈等信息,然后执行 foo 函数中的console.log(1)语句,并打印出 1。

    接下来就执行到 foo 函数中的await 100这个语句了,这里是我们分析的重点,因为在执行await 100这个语句时,JavaScript 引擎在背后默默做了太多的事情,把这个语句拆开,来看看 JavaScript 到底都做了哪些事情。

    当执行到await 100时,会默认创建一个 Promise 对象,代码如下所示:

    let promise_ = new Promise((resolve,reject){
      resolve(100)
    })
    
    • 1
    • 2
    • 3

    在这个 promise_ 对象创建的过程中,可以看到在 executor 函数中调用了 resolve 函数,JavaScript 引擎会将该任务提交给微任务队列。

    然后 JavaScript 引擎会暂停当前协程的执行,将主线程的控制权转交给父协程执行,同时会将 promise_ 对象返回给父协程。

    主线程的控制权已经交给父协程了,这时候父协程要做的一件事是调用 promise_.then 来监控 promise 状态的改变。

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

    promise_.then((value)=>{
       // 回调函数被激活后
      // 将主线程控制权交给 foo 协程,并将 vaule 值传给协程
    })
    
    • 1
    • 2
    • 3
    • 4

    该回调函数被激活以后,会将主线程的控制权交给 foo 函数的协程,并同时将 value 值传给该协程。

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

    以上就是 await/async 的执行流程。正是因为 async 和 await 在背后为我们做了大量的工作,所以我们才能用同步的方式写出异步代码。

  • 相关阅读:
    小程序如何设置配送方式
    Android APP 存储权限(适用高于Android R版本)
    小红书账号管理软件,批量账号发布笔记!
    创建一个链表,用标准io读取文件数据插入链表,读取链表数据插入另一个文件
    Win10远程桌面连接黑屏
    3.1 Windows驱动开发:内核远程堆分配与销毁
    【32. 图中的层次(图的广度优先遍历)】
    win10怎么修复dns配置?DNS配置错误无法上网怎么解决
    解决微信小程序(uniapp)中无法读取本地音频问题
    Java基础15 内存分析和面向对象
  • 原文地址:https://blog.csdn.net/weixin_45658814/article/details/126701287