当前位置: 首页 > 科技观察

多图分析公式Async=Promise+Generator+AutomaticExecutor

时间:2023-03-15 16:07:13 科技观察

大家好,我是二哥。第一部分不仅是Node.js的核心,也是理解今天文章的基础。对于event-loop,Node.js官网有如下描述。希望之前的文章可以帮助大家更好的理解这句话。事件循环允许Node.js通过尽可能将操作卸载到系统内核来执行非阻塞I/O操作——尽管JavaScript是单线程的。在这篇文章中,我们将分析异步的实现机制。文章有点长,有点烧脑。如果没有耐心一次看完,建议分批阅读。异步编程的好处很多,主线程负责策略,工作线程负责机制,非常符合Unix的设计理念:策略和机制分离。发号施令的是战略,做硬活的是机制。Javascript异步编程经历了四个阶段,即回调阶段、Promise阶段、Generator阶段和Async/Await阶段。很快发现callback存在回调地狱和控制问题,Promise就在这个时候出现,解决了这些地狱问题。Promise当然比callback顺畅多了,但是在使用了一段时间之后,码友们发现它的体验还是不如同步代码。我们知道同步代码有一个callback和Promise都无法比拟的优势:代码一行一行的运行。如果任何一行代码被阻塞,CPU将暂停执行,直到阻塞被移除,然后再继续。也就是说,请求发生的地方和请求完成的地方相邻。虽然在时间上有先后顺序,但在空间上却是连续的。有没有一种语法,可以让我们既享受异步编程的好处,又拥有同步编程的体验呢?当然有!它是异步/等待。其实大家一直在使用async/await,早就感受到它的美妙之处:兼具运行效率和扁平化结构。asyncfunctionasynFn(){//代码块1leta1=await(Promiseinstancea)//LINE-A//代码块2//LINE-Breturnxxx}syncFn()但是,对于上面的简单代码,有有几个问题不知道大家有没有想过?LINE-A的await语句表示需要等待后面表达式的结果。“等待”一词意味着变量a1的计算和LINE-B处代码的执行被延迟。这个延迟操作是如何完成的?使用newPromise()创建Promise实例a时,我们需要为其设置一个函数。在此函数中,当我们调用resolved(data)时,a的状态将变为fulfilled。为什么变量a1的值会变成我们调用resolved(data)时设置的实参data呢?异步和图1中所示的单进程多线程模型之间有什么关系?异步是如何实现的?async/await=Promise+Generator+automaticexecutor这是二哥总结的公式。它揭示了async/await和Promise/Generator之间的关系。带着上面的几个问题和这个公式上车。1.Event-loop在开始我们的旅程之前,我们还需要回顾一下上一篇文章中讨论的关键概念:event-loop。它是Node.js的核心。Node.js主线程和线程池的关系如下图所示。主线程负责执行JS代码,线程池中的工作线程负责执行访问DB、访问文件等耗时费力的工作。他们通过消息队列协调他们的工作。这类似于餐厅工作流程。餐厅里,一位漂亮的小姐姐招呼客人入座,负责收拾各桌的点单。每接到一份点好的菜单,小姐姐都会迅速通过一个小窗口递交到后厨。后厨有个小看板,所有订单都显示在看板上。主厨根据单子的时间和内容,安排不同的厨师做饭。菜做好后,由小姐负责上菜。图1:Node.js主线程和工作线程关系图2.什么是PromisePromise?我觉得二哥这里不需要过多介绍。以下是Promise的典型用法:constpromise=newPromise(/*executor*/function(resolve,reject){//...somecodeif(/*asynchronousoperationsucceeded*/){resolve(value);}else{reject(error);}});//变量promise的使用场景1promise.then(value=>{//success}).catch(error=>{console.log(error);});//变量promise的使用场景2promise.then();//变量promise的使用场景3awaitpromise;对于这段代码,二哥这里想说几个重点:Promise是一个Class,所以需要用newPromise()创建Promise对象。Promise也是一个状态机。它有pending、fulfilled(resolved)和rejected三种状态。状态转换只能从pending到resolved或pending到rejected,一旦状态转换完成,就不能再转换。我们调用Promise的then()方法时提供的onResolved/onRejected函数就是回调。它们仅在Promise的状态发生变化时被调用。再次声明:我们使用then()方法设置的回调只会在状态发生变化时被调用。但是,也有可能在调用then(onResolved,onRejected)时,会立即执行两个回调之一:执行then()方法时,Promise的状态已经发生了转换。这什么时候会发生?其实很简单。在创建Promise对象的时候,我们需要提供一个回调,如上面代码所示,这里我们称这个回调为executor。这个executor会被立即执行,newPromise()执行完后会返回,然后我们就可以基于这个Promise对象进行链式调用了。我们只需要在执行器中调用resolve/reject,强制then()立即执行onResolved和onRejected。让我们依次看一下下面的问答:问:onResolved/onRejected函数什么时候执行?答案:当Promise的状态改变时。问:Promise的状态什么时候改变?答:当我们在执行器中调用resolved(value)时。问:那么我们什么时候需要调用resolved(value)?答:当我们的异步请求完成时。Q:谁负责完成异步请求?答:Worker线程负责完成异步请求。当工作线程的异步操作结束时,通过事件队列通知Node.js主线程,并在事件循环的下一个tick执行回调函数。所以这个过程其实就是发起异步请求,请求完成后调用回调函数的过程。这个过程完全按照图1所示的流程进行。3.GeneratorGenerator函数是ES6提供的一种异步编程解决方案,其语法行为与传统函数完全不同。图2:生成器函数示例letg=gen();g.next();//返回{值:300,完成:false}g.next();//return{value:400,done:false}g.next();//return{value:xxx,done:true}图2是一个Generator函数。Generator的语法层面不是本文的重点。二哥在这里写了它和普通函数最明显的区别:对于像gen()这样的函数调用,Generator函数中的代码不会立即执行,也就是函数①位置的代码不会执行.函数调用立即返回一个迭代器。既然是迭代器,我们就可以通过g.next()不断遍历这个Generator的内部状态。每个g.next()调用都会返回一个对象,如{value:xxx,done:xxx}。Generator函数还可以包含关键字yield,如代码②和④所示。yield导致函数的执行暂停。虽然⑥处的语句看起来像returnxxx,但实际上函数返回的结构是{value:xxx,done:true}。我们可以把Generator理解为一个状态机。它的状态将随着Generator函数内的代码继续执行而改变。而我们可以通过g.next()遍历这些状态。(1)区分两个重要的概念有两个重要的概念需要区分,这对理解Generator的本质非常重要:yield表达式和yield语句的关系如图3所示,我也标注了execution图中Generator函数的暂停点。如果你看下面的文字有点头晕,可以回来看看。图3:yield表达式和yield语句函数的比较*gen(){leta=1letb=2leta1=yielda+b//LINE-A//^第一次调用next()暂停位置a=一个??3b=4leta2=yielda*b//LINE-B//^在暂停的位置再次调用next()returna2//LINE-C}//下面是Generator函数callerlet的调用者g=gen()letres=g.next()//第一次调用next()//LINE-D//根据res.value做一些事情//LINE-Eg.next()//第二次调用next()g.next();//return{value:xxx,done:true}//LINE-F就像这段代码,LINE-A处的a+b表达式称为yield表达式,表达式的value结果体现在的value属性中g.next()每次返回的Object,即{value:3,done:false}。yielda+b称为yield语句。它的返回值是多少?默认情况下返回undefined,所以在执行完LINE-A这行代码之后,a1的值是undefined。注意我说的是:LINE-A处的yield语句执行后,a1的值是undefined。yield表达式影响next()方法调用的返回值,进而改变调用者的行为。比如LINE-E的代码执行会受到res.value的影响。yield语句影响LINE-A处的变量a1,这又会改变Generator函数本身的代码行为。例如,a=a1??3变量a的值会受到影响。其实LINE-A的执行分为两个阶段:第一次调用next()从函数的开头开始,直到遇到yield才停止,我在代码中标出了暂停点。在评估表达式a+b后,第一个next()调用返回{value:3,done:false}。对next()的第二次调用将从LINE-A中断的地方继续执行,直到它在LINE-B处遇到停止。在计算表达式a*b后,第二个next()调用返回{value:12,done:false}。中间,a1被赋值了一次。当然,它的值是未定义的。让a1未定义很无聊。我们可以通过在调用next()时传入一个参数来改变yield语句yielda+b的返回值。请注意,我说的是更改yield语句的返回值,而不是yield表达式。与g.next(100)一样,在本例中,a1在第二次调用期间变为100。您认为第二次调用.next()的值是多少?是的,这次是400(100*4)。但是这里有一个限制,我们不能在第一次执行时向g.next()中注入值。例子中的LINE-C终止了Generator函数的执行,所以对它的遍历也终止了。按照前面的示例,在LINE-F中最后一次调用next()的返回值为:{value:400,done:true}。(2)执行权让步还不晕,我们继续。如果您感到头晕,请返回上一步继续阅读。你发现在上面的代码中,CPU在执行Generator函数的时候停顿了两次,而且都是在遇到yield关键字的时候停顿了。每次暂停的时间点都在yield表达式的求值完成之后,但在yield语句返回之前。请根据例子中二哥标注的位置,将这句话多读几遍。Generator函数的执行暂停意味着next()调用立即返回,Generator函数直到下一次next()调用才有机会继续执行。你有没有发现一件有趣的事?每出现一次yieldpause,就意味着Generator函数交出了代码的执行权。通过next()返回这样的机会,执行权就到了调用者。调用者再次调用next(),意味着Generator函数可以重新恢复运行,也就是说,调用者以next()调用为契机,将执行权交还给Generator函数。二哥给这个过程起了个好听的名字:执行权退位。(3)用手动执行器驱动Generator至此,我们已经大致了解了Generator相对于普通函数的显着特点:调用它会立即返回,返回给调用者的是一个迭代器。Generator函数本身不能自动运行,必须由next()启动,每次暂停后必须由next()驱动继续前进。每次调用迭代器的next()都会使Generator获得代码执行权,并被驱动继续运行,直到遇到下一个yield关键字或return语句。遇到yield关键字意味着next()调用应该返回,也意味着Generator函数应该交出代码执行权。伴随next()返回一个对象,如{value:xxx,done:xxx}。值部分是通过评估yield表达式获得的。我们还可以通过给next()传递参数来控制yield语句的返回值。我们把上面提到的调用者写得更完整一些,如下图手动执行器旁边的代码块所示,我用紫色数字标记了每一行代码。然后稍微修改一下图1中的示例代码,把yield表达式改成一个Promise对象。同样,我用黄色数字标记了每一行代码。下面我用紫色①表示左边第一行代码,同样用黄色①表示右边第一行代码。下面我们看一下用手动执行器驱动Generator的过程。整个过程从紫色①代码g=gen()的执行开始,到紫色④结束。在图3中,我已经详细标注了每次g.next()调用引起的代码执行权限的变化,Generator函数的暂停和恢复,以及next()调用的返回值。在看这张时序图的时候,希望大家能注意到以下细节:Purple②toPurple④每次调用next(),都意味着手动执行器将代码执行权交还给Generator。而当next()调用返回时,意味着手动执行者已经获得了再次执行代码的权利。紫色②处执行代码g.next()得到的值的数据类型是一个Promise对象a。所以调用者需要在其上调用then(onResolved)并等待onResolved被执行。紫色③处的代码在紫色②设置的onResolved回调中执行。这意味着只有当Promise对象a的状态转换完成后,Generator才有机会获得执行权,继续执行下去。紫色③处的代码g.next(data)在执行时,传入了一个data,也就是说当Generator重新获得执行权时,右边黄色②处的变量a1的值为data。我们仔细想想,数据从哪里来?第一:右边黄色②处,yield表达式Promise对象a通过iterator的遍历传回给左边紫色②;then:在Promisea的执行器中执行resolve(data)后,出现data;finally:然后通过next(data)注入Generator,改变黄②处yield语句的返回值,将数据交给变量a1。这是一个非常巧妙的过程。通过这样一个过程,我们不仅可以利用上面提到的Generator的特性来控制右边代码的执行节奏,还可以将左边代码的执行结果带回右边。右边黄色②处的代码,如果我们把yield改成await,刚才说的这个过程是不是实现了await的语义?紫色④处的代码与紫色③类似,不再赘述。图3:手动执行器驱动的Generator时序图4.自动执行器上的手动执行器可以用来解释Generator的执行过程,但没有实际作用,因为yield语句有多少就有多少generator,对应的数字必须手写。value.then(),想想都觉得累。所以需要构建一个可以忽略Generator中yield语句数量的自动执行器。这样的自动执行器如图5右侧所示,代码来自阮一峰的《ECMAScript 6 入门》。致动器的入口在右边是紫色⑦。很容易理解,就不细说了。通过这样一个自动执行器,我们可以驱动任意一个Generator函数,在执行权的左右交换之间得到需要的数据。图5:Genetaror+自动执行器5、async/await恭喜你,到现在你还没有放弃。我们离终点线不远了。async函数实际上是Generator函数的语法糖。那么它究竟是如何给生成器涂上糖衣并将其提供给我们的呢?并且看图6,最右边的asyncfunction和最左边的Generator在代码结构上没有区别,只是关键字function*换成了asyncfunction,yield换成了await。通常我们将async/await一起使用,而await只能用来等待一个Promise对象,所以yield表达式部分也是一个Promise对象。因为Generator不能自己执行,所以它配备了一个自动执行器。看到这里,是不是突然明白了:为什么await的target一定是Promise对象(如果target是value、string、Boolean等原始类型的值,会自动转成Promise对象,即刻解决)?图6:async/await=Promise+Generator+automaticexecutor6.代码又写到这里了,让二哥总结一下:async函数本质上是一个generator函数,自动执行器和自动执行器的配合过程generator其实就是不断操作各种Promise对象的过程,而Promise对象完全是基于图1所示的event-loop来工作的。那么,我们来看一下上一篇文章开头的那段代码。whileLoop_1()和whileLoop2()这两个函数都是异步函数。剥开后我们会发现它们其实分别在LINE-A和LINE-B产生了异步请求。对于主线程来说,这样的异步请求不会影响它继续执行其他的JS代码,可见CPU不会陷入这两种死循环中的任何一种。'usestrict';asyncfunctionsleep(intervalInMS){returnnewPromise((resolve,reject)=>{setTimeout(resolve,intervalInMS);});}asyncfunctionwhileLoop_1(){while(true){try{console.log('新一轮whileLoop_1');等待睡眠(1000);//LINE-A继续;}catch(error){//...}console.log('whileLoop_1结束');}}asyncfunctionwhileLoop_2(){while(true){try{console.log('新一轮的whileLoop_2');等待睡眠(1000);//LINE-B继续;}赶上(错误){//...}控制台。log('whileLoop_2结束`');}}whileLoop_1();//LINE-CwhileLoop_2();//LINE-D,我们看到无论是最早的callback还是Promise,再到async/await,本质上都是异步的都是充分利用了Node.js最核心最基础的架构event-loop,最大化并发度提高系统资源的利用率。正在不断改进。Node.js的事件循环架构是典型的事件驱动架构(event-drivenarchitecture)。我们停下忙碌的工作,思考软件运行的意义,梳理软件开发模型的演进过程。我们会发现,无论是早期的单体架构还是面向服务的架构,到现在,从红色到紫色的微服务架构(microservicearchitecture),它们存在的意义和演进的目的都没有改变,那就是:尽一切可能响应事件。