20 使用同步的方式写异步代码

122 阅读9分钟

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

async function foo(){  
    try{  
        let response1 = await fetch('https://www.geekbang.org')  
        console.log('response1')  
        console.log(response1)  
        let response2 = await fetch('https://www.geekbang.org/test')  
        console.log('response2')  
        console.log(response2)  
    }catch(err) {  
        console.error(err)  
    }  
}  
foo()

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

本文我们首先介绍生成器(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. main 0
2. 开始执行第一段
3. generator 2

4. main 1
5. 开始执行第二段
6. generator 2

....
*/

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

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

外部函数可以通过next方法恢复函数的执行。

要搞懂函数为何能暂停和恢复,那你首先要了解协程的概念。协程是一种比线程更加轻量级的存在。

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

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

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

截屏2023-05-14 下午5.07.50.png

next 进入协程 yeild暂停 return 退出协程。

通过调用生成器函数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协程的调用栈信息。

截屏2023-05-14 下午5.40.00.png

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

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

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)  
})

/*
    首先执行的是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()); // 相当于不停的next,所有的任务都在foo中执行,用co不停的获取结果next.

await/async

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

async

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

异步执行隐式返回Promise

如何隐式返回Promise的?

async function foo() { return 2 }

console.log(foo()) // Promise {: 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)
    
/*

1. console.log(0)  
2. 紧接着就是执行foo函数,由于foo函数是被async标记过的(**异步**),所以当进入该函数的时候,JavaScript引擎会保存当前的调用栈等信息,然后执行foo函数中的console.log(1)语句,并打印出1。
3.接下来就执行到foo函数中的await 100这个语句了,这里是我们分析的重点,因为在执行await 100这个语句时,JavaScript引擎在背后为我们默默做了太多的事情,那么下面我们就把这个语句拆开,来看看JavaScript到底都做了哪些事情。
    
当执行到await 100时,会默认创建一个Promise对象,代码如下所示:
*/    
    
let promise_ = new Promise((resolve,reject){  
    resolve(100)  
})

/*
在这个promise_对象创建的过程中,我们可以看到在executor函数中调用了resolve函数,JavaScript引擎会将该任务提交给微任务队列防止没有then就执行了。
*/

/*
主线程的控制权已经交给父协程了,这时候父协程要做的一件事是调用promise_.then来监控promise状态的改变。
    
接下来继续执行父协程的流程,这里我们执行console.log(3),并打印出来3。随后父协程将执行结束,在结束之前,会进入微任务的检查点,然后执行微任务队列,微任务队列中有resolve(100)的任务等待执行,执行到这里的时候,会触发promise_.then中的回调函数,如下所示:
*/
    
promise_.then((value)=>{  
    //回调函数被激活后  
    //将主线程控制权交给foo协程,并将vaule值传给协程  
})

/*
该回调函数被激活以后,会将主线程的控制权交给foo函数的协程,并同时将value值传给该协程。
    
foo协程激活之后,会把刚才的value值赋给了变量a,然后foo协程继续执行后续语句,执行完成之后,将控制权归还给父协程。    
*/

async function foo() {  
    console.log('foo')  
}  
    
async function bar() {  
    console.log('bar start')  
    await foo()  // 带
    console.log('bar end')  
}  
    
console.log('script start') // 1 
    
setTimeout(function () {  
    console.log('setTimeout')  
}, 0)  
    
bar();  
    
new Promise(function (resolve) {  
    console.log('promise executor')  
    resolve();  
}).then(function () {  
    console.log('promise then')  
})  
    
console.log('script end')
    

首先在主协程中初始化异步函数foo和bar,碰到console.log打印scriptstart;

2.解析到setTimeout,初始化一个Timer,创建一个新的task

3.执行bar函数,将控制权交给协程,输出barstart,碰到await,执行foo,输出foo,创建一个Promise返回给主协程

4.将返回的promise添加到微任务队列,向下执行newPromise,输出promiseexecutor,返回resolve添加到微任务队列

5.输出scriptend

6.当前task结束之前检查微任务队列,执行第一个微任务,将控制器交给协程输出barend

7.执行第二个微任务输出promisethen

8.当前任务执行完毕进入下一个任务,输出setTimeout