标题:多图剖析公式 Async=Promise+Generator+自动执行器
时间:2022/09/05
分享

大家好,我是二哥。

​上篇​既是 Node.js 的核心,也是理解今天这篇的基础。对 event-loop ,Node.js 官网有下面这样一段描述。希望上一篇能帮你更好地理解这句话。

The event loop is what allows Node.js to perform non-blocking I/O operations  despite the fact that JavaScript is single-threaded  by offloading operations to the system kernel whenever possible.

这篇我们来剖析 async 的实现机制。文章有点长,还略为烧脑。如果没有耐心一次看完,建议分批次看。

异步编程的好处多多,主线程负责策略,工作线程负责机制,完美匹配 Unix 的设计哲学:策略和机制分离。发号施令的是策略,苦逼干活的是机制。

Javascript 异步编程先后经历了四个阶段,分别是 callback 阶段,Promise 阶段,Generator 阶段和 Async/Await 阶段。

callback 很快就被发现存在回调地狱和控制权问题,Promise 就是在这个时间出现的,用来解决这些地狱问题。Promise 用起来的感觉当然是比 callback 丝滑太多,但码农们使用一段时间后发现它的使用体验还是比不上同步代码。

我们知道同步代码有一个无论 callback 还是 Promise 都无法比拟的优点:代码是一行一行运行的。如果哪行代码被阻塞了,CPU就暂停运行,直到阻塞解除后再继续。也就说请求发生的地方和请求完成的位置是挨在一起的,虽然时间上有先后,但空间上却是连续的。

那有没有一种语法,能让我们既享受到异步编程的好处,又能有同步编程那样的体验呢?当然有!它就是 async/await 。其实大家一直在用 async/await ,也早就感受到它的优美了:兼具运行效率与结构扁平。

async function asynFn(){
// code block 1
let a1 = await ( Promise instance a ) // LINE-A
// code block 2 // LINE-B
return xxx
}
syncFn()

不过,对于上面这段简单的代码,有几个问题不知道你想过没?

async/await = Promise + Generator + 自动执行器

这是二哥总结的公式。它揭示了 async/await  和 Promise / Generator 之间的关系。上车吧,带着上面的几个问题和这个公式。

1、event-loop

在开启我们的旅程之前呢,还是要先来复习上一篇聊到的至关重要的概念:event-loop 。它是 Node.js 的核心。

Node.js 主线程和线程池的配合关系如下图所示。主线程负责执行 JS  code ,线程池里面的 work thread 负责执行类似访问 DB、访问文件这样的耗时费力的工作,它俩通过消息队列协调工作。

这和餐馆工作流程类似。餐馆由一个长得漂亮的小姐姐招呼客人落座并负责收集来自各个餐桌的点单。每次收到一个点好的菜单,小姐姐会迅速地把它通过一个小窗口递交给后厨。后厨那里有一个小看板,所有的点单都被陈列在看板上。厨师长根据单子的时间和内容安排不同的厨师烧菜。菜烧好后,再由小姐姐负责上菜。

图片

图 1:Node.js 主线程和工作线程关系图

2、Promise

Promise 是什么?我想不需要二哥在这里做过多介绍了。下面是 Promise 的典型使用方法介绍:

const promise = new Promise(/*executor*/ function(resolve, reject) {
// ... some code
if (/* 异步操作成功 */){
resolve(value);
} else {
reject(error);
}
});
// 对变量 promise 的使用场景 1
promise.then(value => {
// success
})
.catch(error => {
console.log(error);
});
// 对变量 promise 的使用场景 2
promise.then();
// 对变量 promise 的使用场景 3
await promise;

对这段代码,二哥想在这里说几个重点:

  1. Promise 是一个 Class,所以需要用 new Promise() 来创建一个 Promise 对象。
  2. Promise 还是一个状态机。它有三种状态  pending,fulfilled(resolved) 和 rejected。状态转换只能是 pending 到 resolved 或者 pending 到 rejected,且状态一旦转换完成,不能再次转换。
  3. 我们调用 Promise 的then() 方法时所提供的 onResolved / onRejected 函数均是 callback。只有当 Promise 的状态改变后,它们才会被调用。再强调一遍:只有当状态改变后,我们通过 then() 方法所设置的 callback 才会被调用。不过也有可能调用 then(onResolved, onRejected) 时,这俩 callback 之一会被立刻执行:当执行 then() 方法的时候,Promise的状态已经转换完成了。什么时候会发生这种情况呢?其实很简单,创建 Promise 对象的时候,我们需要提供一个 callback ,如上面的代码所示,这里我们称这个 callback 为 executor。这个 executor 是会被立即执行的,等它执行完了,new Promise() 才会返回,这时我们才可以基于这个 Promise 对象进行链式调用。我们只要在 executor 里面调用 resolve / reject 就可以迫使 then() 立即执行 onResolved, onRejected 了。

我们反过来过一遍下面的自问自答:

所以这个过程其实是发起异步请求和请求完成后的 callback 函数调用过程。这个过程完全遵循图1 所示的流程。

3、Generator

Generator 函数是 ES6 提供的一种异步编程解决方案,语法行为与传统函数完全不同。

图片

图 2:Generator 函数示例

let g = gen();
g.next(); // return { value: 300, done: false }
g.next(); // return { value: 400, done: false }
g.next(); // return { value: xxx, done: true }

图 2 即为一个 Generator 函数。Generator 语法层面的东西不是这篇文章的重点。二哥把它与普通函数最明显的区别写在这里:

我们可以把 Generator 理解为一个状态机。它的状态会随着 Generator 函数内部代码的不断执行而改变。而我们可以通过 g.next() 来遍历这些状态。

(1)区分两个重要的概念

有两个重要的概念需要区分开来,这对理解 Generator 的精华非常重要:

它们之间的关系如图 3 所示。我还在图中标出了 Generator 函数执行暂停点,阅读后文的时候如果被绕晕了,可以回到这里来看看。

图片

图 3:yield 表达式和 yield 语句对比

function * gen(){
let a = 1
let b = 2
let a1 = yield a+b // LINE-A
// ^ 第一次调用 next() 暂停的位置
a = a1 ?? 3
b = 4
let a2 = yield a*b // LINE-B
// ^ 再次调用 next() 暂停的位置
return a2 // LINE-C
}
// 以下为 Generator 函数调用者 caller
let g = gen()
let res = g.next() // 第一次调用 next() // LINE-D
// do something accroding to res.value // LINE-E
g.next() // 第二次调用 next()
g.next(); // return { value: xxx, done: true } // LINE-F

像这个代码里面,LINE-A 处的 a+b 表达式称为 yield 表达式,表达式的求值结果体现在每次  g.next() 所返回的 Object 的 value 属性上,也即 {value : 3, done: false} 。而 yield a+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 的执行被分成了两个阶段:

老让 a1 为 undefined 多没意思,我们可以通过在调用 next() 时传进去一个参数来改变 yield a+b 这条 yield 语句的返回值,注意我说的是改变 yield 语句的返回值,不是 yield 表达式。就像 g.next(100) 这样,这样的话,在第二次调用过程中, a1 就变成 100 了。你猜,第二次调用 .next() 得到的 value 是多少?对,这次它是 400(100*4)。

不过这里有个限制,我们不能在第一次执行  g.next() 的时候给它注入一个值。

示例中的 LINE-C 使得 Generator 函数执行终止,故对它的遍历也就终结了。接着刚才的例子,LINE-F 处最后一次 next() 调用得到的返回值是:{value : 400, done: true} 。

(2)执行权禅让

如果你还没有晕的话,我们继续。如果你晕了的话,返回上一步继续读。

你发现了,上面的代码里,CPU 在执行 Generator 函数的时候,暂停了两次,且都是遇到 yield 这个关键词的时候暂停的。

每次暂停的点都是在 yield 表达式求值结束之后,但 yield 语句返回之前。请结合二哥在示例中标注的位置,把这句话多读几次。

Generator 函数的执行暂停意味着 next() 调用立即返回了,直到下一次 next() 调用,Generator 函数才又得到了可以继续执行的机会。

你有没有发现一个有意思的事情?

二哥给这个过程取了一个好听的名字:执行权禅让。

(3)用手动执行器驱动 Generator

到目前为止,我们大概了解了 Generator 相比普通函数的鲜明特征:

我们把前文所提到的调用者写得完整一些,如下图手动执行器旁边的代码块所示,代码的每一行我用紫色数字标记出来了。再把图 1的示例代码稍作修改,把 yield 表达式改为一个 Promise 对象,同样地,代码每一行我用黄色数字做了标记。后文我用紫 ① 表示左侧代码第一行,类似地用黄 ① 表示右侧代码第一行。

让我们来看看用手动执行器来驱动 Generator 的过程。整个过程从紫 ① 代码 g = gen() 执行开始,到紫 ④ 结束。我在图 3 中详细标注了每一次 g.next() 的调用所引发的代码执行权的更替以及 Generator 函数的暂停和恢复情况,还有 next() 调用的返回值。

在看这个时序图的时候,希望你能注意到下面几个细节:

图片

图 3:手动执行器驱动 Generator 时序图

4、自动执行器

上面的手动执行器用来解释 Generator 的执行过程可以,但没有实用功能,因为 Generator 里面有多少个 yield 语句,就得手写对应个数的 .value.then() ,想想就觉得很累。所以搞一个可以无视 Generator 里面 yield 语句个数的自动执行器很有必要。

图 5 右侧就是这样的自动执行器。代码源自阮一峰的《ECMAScript 6 入门》。执行器的入口是右侧紫 ⑦ 。很容易看懂,我就不多讲了。

通过这样的自动执行器,我们可以驱动任意一个 Generator 函数,并在执行权利的左、右侧交换之间得到所需的数据。

图片

图 5:Genetaror + 自动执行器

5、async / await

恭喜你,坚持到现在还没有放弃。我们离终点不远啦。

async 函数其实是 Generator 函数的语法糖。那它到底是如何给 Generator 包裹上了糖衣并投喂给我们的呢?且看图 6 。

最右侧的 async 函数和最左侧的 Generator 在代码结构上没有任何区别,只是把关键词 function * 替换成了 async function ,把 yield 替换成了 await 。通常情况下我们是 async/await 搭配使用的,await 只能用于 wait 一个 Promise 对象,所以 yield 表达式部分也是一个 Promise 对象。因为 Generator 没法自己执行的缘故,所以再搭配一个自动执行器。

看到这里,你是不是猛然理解了:为什么 await 的目标必须是一个 Promise 对象(如果目标是原始类型的值如数值、字符串和布尔值等,会被自动转成立即 resolved 的 Promise 对象)?

图片

图 6:async/await = Promise + Generator + 自动执行器

6、代码再回首

写到这里,让二哥来做一个总结:

async 函数本质上就是一个 Generator 函数,自动执行器和 Generator 的合作过程其实就是不断操作各种 Promise 对象的过程,而 Promise 对象又完整地基于图 1 所示的 event-loop 在工作。

好了,我们再来看看​上一篇​开头处的那段代码。whileLoop_1() 和 whileLoop2() 这两个函数都是 async 函数。将其抽丝剥茧后,我们会发现它们其实就是分别在 LINE-A 处和 LINE-B 处产生了异步请求。对于主线程而言,这样的异步请求不会影响它继续执行其它的 JS code,所以我们能看到 CPU 不会陷入这两个死循环中的任意一个。

'use strict';
async function sleep(intervalInMS)
{
return new Promise((resolve,reject)=>{
setTimeout(resolve,intervalInMS);
});
}
async function whileLoop_1(){
while(true){
try {
console.log('new round of whileLoop_1');
await sleep(1000); // LINE-A
continue;
} catch (error) {
// ...
}
console.log('end of whileLoop_1');
}
}
async function whileLoop_2(){
while(true){
try {
console.log('new round of whileLoop_2');
await sleep(1000); // LINE-B
continue;
} catch (error) {
// ...
}
console.log('end of whileLoop_2`');
}
}
whileLoop_1(); // LINE-C
whileLoop_2(); // LINE-D

我们看到无论是最早的 callback 还是 Promise, 再到 async/await 本质上都是异步编程模型,它们都是在充分利用 Node.js 的 event-loop 这个最核心的、最基础的架构,最大化地提高并发度以提高系统资源利用率,同时在对程序员的编程友好度上也在不断地提升。

Node.js 的 event-loop 这个架构是典型的事件驱动架构( event-driven architecture)。我们停下手中忙不完的工作,思考一下软件运行的意义,梳理一下软件开发模式的演进历程,会发现无论是早期的单体巨石(monolithic)架构还是面向服务架构(service-oriented architecture),再到现在红到发紫的微服务架构(microservice architecture),它们存在的意义以及进化的目的一直都没有改变,那就是:尽一切可能,响应事件。

本文仅代表文章作者的个人观点,请读者仅作参考,并自行核实相关内容。如有侵权请与我们联系,我们将及时删除。
推荐资讯
进入资讯频道查看更多新闻