前言
这是我在极客时间李兵老师课程《浏览器工作原理与实践》 async/await:使用同步的方式去写异步代码 一课中的学习的心得与自己的理解。
起因
我们知道Promise的出现极大地解决了回调地狱,但是如果使用流程非常复杂的话,就非常容易过多地调用Promise的then()方法,这样也不利于使用和阅读。
例如:
我希望在请求 www.baidu.com 后先输出请求的结果,再去请求 www.taobao.com 后再输出请求结果,如果只用Promise实现,那么代码就是下面的样子:
fetch('https://www.baidu.com').then(res => {
console.log(res)
return fetch('https://www.taobao.com')
}).then(res => {
console.log(res)
}).catch(err => {
console.log(err)
})
这只是去请求两个网站,如果流程一旦多了,那么then()方法的调用也是随之增加的,虽然整个过程较为线性,但是代码的阅读性依然很差。所以,在es7中提出了async/await来让我们能够用同步的方式来实现异步,但是要记住async/await只是一个语法糖,其本质依然还是异步的,不过是让我们可以用同步的方式来书写而已。我们可以看一下下面的代码,同样是解决上面的的问题:
async function foo(){
try{
const result1 = await fetch('https://www.baidu.com')
console.log(result1)
const result2 = await fetch('htpps://www.taobao.com')
console.log(result2)
} catch(err){
console.log(err)
}
foo()
使用async/await让代码的书写就和同步一样,并且还可以使用try catch来捕获错误。试问一下,这两种书写方式,对于程序员来说会选择哪一个呢?
原理
在我看来,async/await的使用就好像是把函数暂停了一样,当执行到await时,函数暂停执行,直到await等待的Promise状态改变了,才会回到这个函数执行。这种方式是不是很眼熟?是的,这简直就跟生成器的工作方式一模一样,所以要弄懂async/await的原理前,我们要先了解一下生成器是如何工作的。不过在了解生成器之前,我先介绍一下协程这个概念。
协程
协程是一种比线程更轻量级的存在,它不由cpu直接调度,而是在用户态可以通过程序来操纵。 你可以理解为协程是跑在线程上的任务,一个线程上可以有多个协程,但是一个线程同时只能执行一个协程。比如,当前在主线程上执行的是A协程,如果这个时候要启动B协程,A协程就需要将主线程的控制权让出来,并且暂停执行,B协程开始在主线程上运行。同样的,在B协程中也可以启动A协程。通常,如果在A协程中启动B协程,我们把A协程称作B协程的父协程。
协程带来的好处就是可以提升性能,协程的切换并不会像线程切换那样过多地消耗资源。了解了协程的存在,我们就可以知道为什么会有生成器了。
生成器
什么是生成器?就是前面带有*的一个函数,这个函数可以暂停执行或者恢复执行。在函数内部可以使用yield关键字来暂停函数的执行,切换到外部函数,其实就是协程的切换。在外部函数中可以通过调用next()方法来恢复函数的执行。
function * bar(){
console.log('step 1')
yield 1
console.log('step 2')
yield 2
console.log('step 3')
yield 3
}
const gen = bar()
console.log(gen().next().value)
console.log(gen().next().value)
console.log(gen().next().value)
执行上述代码,你就会发现这个函数不是立马就被执行完的,而是在分布执行,我们可以分析一下:
- 执行生成器bar,此时主线程执行bar函数协程,打印step 1。
- 执行到yield关键字,bar函数协程暂停执行,转到外部函数协程,并返回yield关键字抛出的值。
- 外部函数协程执行next()方法,该方法的返回值的value属性值就是yield抛出的值,打印1,并切换协程,主线程执行bar函数协程。
- 打印step 2, 执行到yield关键字,bar函数协程暂停执行,转到外部函数协程,并返回yield关键字抛出的值。
- 外部函数协程执行next()方法,该方法的返回值的value属性值就是yield抛出的值,打印2,并切换协程,主线程执行bar函数协程。
- 打印step 3, 执行到yield关键字,bar函数协程暂停执行,转到外部函数协程,并返回yield关键字抛出的值。
- 外部函数协程执行next()方法,该方法的返回值的value属性值就是yield抛出的值,打印3,并切换协程,主线程执行bar函数协程
- bar函数执行完毕,该协程销毁,转到外部函数协程继续执行代码。
通过上面生成器与协程的分析,想必
async/await的原理已经要呼之欲出了,那么让我们来正式开始async/await的原理揭秘。
async/await
async
async在MDN上的定义就是一个通过异步执行并隐式返回一个Promise作为结果的函数。
我们需要关注两个地方,异步执行和隐式返回Promise, 异步执行先放到后面说,对于隐式返回Promise,我们可以通过代码来一窥究竟。
async function foo() {
return 2
}
console.log(foo()) // Promise {: 2}
执行这段代码,我们可以看到调用 async 声明的 foo函数返回了一个 Promise 对象,状态是 resolved。
await
await才是真正的主菜
我们来通过一段代码来分析await到底做了什么
async function foo(){
console.log(1)
const result = await 2
console.log(result)
}
console.log(3)
foo()
console.log(4)
先试着看下打印的是什么哦?
为什么是 3 1 4 2呢?明明是先执行了foo函数再打印的4,为什么4会在2前面呢?这就要提到我们之前说的生成器了,await正是在这基础上做到的。下面我们站在生成器和协程的角度来看下这段代码是如何执行的。
1.首先由于foo函数被async标记过,所以当进入该函数的时候,JavaScript 引擎会保存当前的调用栈等信息。
2.全局执行上下文在主线程上执行执行,我们在这里暂且将全局执行上下文叫做父协程, 首先打印3。
-
foo函数执行,主线程控制权由父协程转为foo函数协程,打印1。 -
执行
await 2,在这里做了两件事,第一是新建一个Promise
let Promse_ = new Promse((reslove, reject) => {
resolve(2)
})
在该Promise的创建中我们看到在执行器中调用了resolve函数,这时JavaScript引擎会将该任务放入微任务队列中。
第二件事就是暂停foo函数的执行,也就是做了yield关键字的事情,切换协程,将主线程的控制权交给父协程,并向父协程返回创建的Promise。
当父协程恢复执行时,会通过调用返回的Promise的then()方法来监听这个Promise的状态,当这个Promise状态改变时会再次切换协程,将主线程控制权交给foo函数协程。我们可以通过下面一段伪代码模拟:
Promise_.then(res => {
foo().next(res) //因为async返回的是Promise,所以没有next方法,但是内部实现原理是一致的,可以当作参考
})
-
父协程在主线程上运行,打印4。
-
父协程上的代码都执行完了,到达微任务队列的检查点,发现了微任务队列中有
reslove(2)需要执行,执行这个任务的时候,会执行该Promise的then()方法注册的所有回调函数,也就是上述的代码,这时,协程再次切换,主线程上运行foo函数协程,foo函数继续执行,打印2。
以上就是 await/async 的执行流程。正是因为 async 和 await 在背后为我们做了大量的工作,所以我们才能用同步的方式写出异步代码来。
总结
Promise的出现帮助我们解决了回调地狱,但是也带来了一个问题——then方法的大量使用,同样使得代码不易阅读。而async/await的诞生就是为了让我们通过同步的方式来使用异步,这样极大的简化了我们的代码,是我们代码的易读性大大提高。而async/await的实现离不开生成器和协程的概念,正是通过生成器可以自由地切换协程才使得我们可以暂停和恢复一个函数的执行。同样,v8引擎也做了许多事,内部做了大量的语法封装才使得我们能够使用async/await语法糖。
不过通过async标记的函数返回值一定是Promise,具有一定的传染性,所以async/await虽好,但是切莫贪杯哦~