多图剖析公式 async=Promise+Generator+自动执行器
大家好,我是二哥。
上篇既是 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()
不过,对于上面这段简单的代码,有几个问题不知道你想过没?
- LINE-A 处的 await 语句表示需要等待后面的表达式的结果。“等待”这两个字意味着变量 a1 的求值和 LINE-B 处代码的执行时机被延后了。
这个延后操作是怎么做到的呢?
用 new Promise() 创建 Promise instance a 时我们需要给它设置一个函数。在这个函数里,当我们调用 resolved(data) 后,a 的状态就会变为 fulfilled ,为什么变量 a1 的值就会变成我们调用 resolved(data) 时所设的实参 data 呢? - async 和图 1 所示的单进程多线程模型之间是什么关系?
- async 是怎么实现的?
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;
对这段代码,二哥想在这里说几个重点:
- Promise 是一个 Class,所以需要用 new Promise() 来创建一个 Promise 对象。
- Promise 还是一个状态机。它有三种状态 pending,fulfilled(resolved) 和 rejected。状态转换只能是 pending 到 resolved 或者 pending 到 rejected,且状态一旦转换完成,不能再次转换。
- 我们调用 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 了。
我们反过来过一遍下面的自问自答:
- 问:onResolved / onRejected 函数什么时候会被执行?
答:当 Promise 的状态改变的时候。 - 问:Promise 的状态什么时候改变?
答:当我们在 executor 里面调用 resolved(value) 的时候。 - 问:那我们什么时候需要调用 resolved(value) ?
答:当我们的异步请求做完了的时候。 - 问:异步请求由谁来负责完成?
答:Worker thread 来负责完成异步请求,当 worker thread 的异步操作结束后,通过 event-queue 通知 Node.js 主线程,并在 event-loop 的下一个 tick 择机执行 callback 函数。
所以这个过程其实是发起异步请求和请求完成后的 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 语法层面的东西不是这篇文章的重点。二哥把它与普通函数最明显的区别写在这里:
- 像
gen()
这样的函数调用,Generator 函数里的代码不会被立即执行,也即该函数 ① 位置的代码不会被执行。
函数调用会立即返回一个迭代器。既然是迭代器,那么我们可以不断地通过 g.next()
来遍历这个 Generator 内部的状态。每次 g.next()
调用会返回 { value: xxx, done: xxx} 这样的 object 。 - Generator 函数还可以包含一个关键字 yield ,如 ② 和 ④ 处代码所示。yield 会使得函数的执行暂停。
- ⑥ 处的语句虽然看起来是
return xxx
,不过实际上该函数返回的却是 { value: xxx, done: true } 这样的结构。
我们可以把 Generator 理解为一个状态机。它的状态会随着 Generator 函数内部代码的不断执行而改变。而我们可以通过 g.next()
来遍历这些状态。
3.1 区分两个重要的概念
有两个重要的概念需要区分开来,这对理解 Generator 的精华非常重要:
- yield 表达式
- yield 语句
它们之间的关系如图 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 的执行被分成了两个阶段:
- 第一次调用 next() 从函数起始处开始执行,直到遇到 yield 停下来,我在代码里标明了暂停点。将表达式
a+b
求值后,第一次 next() 调用返回 {value : 3, done: false} 。 - 第二次调用 next() 会从 LINE-A 暂停处继续执行,直到遇到 LINE-B 处的 yield 停下。将表达式
a*b
求值后,第二次 next() 调用返回 {value : 12, done: false}。
这中间 a1 被赋值一次。当然,它的值为 undefined。
老让 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() 调用如果改成next(400)的话,得到的返回值是:{value : 400, done: true} 。
3.2 执行权禅让
如果你还没有晕的话,我们继续。如果你晕了的话,返回上一步继续读。
你发现了,上面的代码里,CPU 在执行 Generator 函数的时候,暂停了两次,且都是遇到 yield 这个关键词的时候暂停的。
每次暂停的点都是在 yield 表达式求值结束之后,但 yield 语句返回之前。请结合二哥在示例中标注的位置,把这句话多读几次。
Generator 函数的执行暂停意味着 next() 调用立即返回了,直到下一次 next() 调用,Generator 函数才又得到了可以继续执行的机会。
你有没有发现一个有意思的事情?
- 每次遇到 yield 暂停,意味着 Generator 函数把代码的执行权交出来了,通过 next() 返回这样的契机,执行权来到了 caller 手上。
- caller 再次调用 next() 意味着 Generator 函数又得以恢复运行,也就是说 caller 又以 next() 调用这样的契机把执行权递交回 Generator 函数。
二哥给这个过程取了一个好听的名字:执行权禅让。
3.3 用手动执行器驱动 Generator
到目前为止,我们大概了解了 Generator 相比普通函数的鲜明特征:
- 对它的调用会立即返回,返回给调用者的是一个迭代器。
- Generator 函数自己不能自动运行,得通过 next() 启动执行,每次暂停后,还得再通过 next() 驱动它继续往前走。
- 每一次调用迭代器的 next() 会使得 Generator 获得代码执行权,并被驱使继续往前运行,直到遇到下一个 yield 关键词或者 return 语句。
- 遇到 yield 关键词就意味着本次 next() 调用该返回了,也意味着 Generator 函数该交出代码执行权了。伴随着 next() 返回的是 { value: xxx, done: xxx} 这样的 object 。其中 value 部分是对 yield 表达式求值得到。
- 我们还可以通过给 next() 传递参数从而控制 yield 语句的返回值。
我们把前文所提到的调用者写得完整一些,如下图手动执行器旁边的代码块所示,代码的每一行我用紫色数字标记出来了。再把图 1的示例代码稍作修改,把 yield 表达式改为一个 Promise 对象,同样地,代码每一行我用黄色数字做了标记。后文我用紫 ① 表示左侧代码第一行,类似地用黄 ① 表示右侧代码第一行。
让我们来看看用手动执行器来驱动 Generator 的过程。整个过程从紫 ① 代码 g = gen()
执行开始,到紫 ④ 结束。我在图 3 中详细标注了每一次 g.next()
的调用所引发的代码执行权的更替以及 Generator 函数的暂停和恢复情况,还有 next() 调用的返回值。
在看这个时序图的时候,希望你能注意到下面几个细节:
- 紫 ② 至 紫 ④ 每一次对 next() 的调用都意味着手动执行器把代码执行权交还给了 Generator 。而当 next() 调用返回后,意味着手动执行器又获得了代码执行权。
- 紫 ② 处代码
g.next()
执行所得到的 value 的数据类型是一个 Promise 对象 a 。所以调用者需要对其调用 then(onResolved) 并等待 onResolved 被执行。 - 紫 ③ 处代码是在紫 ② 所设置的 onResolved callback 里执行的。这意味着只有当 Promise 对象 a 的状态转换完成,Generator 才有机会拿到执行权并继续往前执行。
- 紫 ③ 处代码
g.next(data)
执行的时候,传入了一个 data 。这意味着当 Generator 重新获得执行权后,右侧黄 ② 处的变量 a1 的值为 data 。
我们来仔细想一下,data 是从哪里冒出来的呢?
首先:右侧黄 ② 处, yield 表达式 Promise 对象 a 先通过迭代器的遍历回传到了左侧紫 ② 处;
然后:在 Promise a 的 executor 里执行 resolve(data)
后,data 出现了;
最后:再通过 next(data)
的方式注入到 Generator ,改变了黄 ② 处 yield 语句的返回值,也就把 data 交到了变量 a1 的手上 。
这真是一个非常巧妙的过程。通过这样的过程,我们既利用前面所说的 Generator 的特征控制住了右侧代码的执行节奏,还把左侧代码的执行结果带回给了右侧。
右侧黄 ② 处的代码,如果我们把 yield 改成 await ,刚才所说的过程是不是就实现了 await 的语义? - 紫 ④ 处代码和紫 ③ 类似,我就不细说了。
图 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),它们存在的意义以及进化的目的一直都没有改变,那就是:尽一切可能,响应事件。
以上就是本文的全部内容。码字不易,画图更难。喜欢本文的话请帮忙转发或点击“在看”。您的举手之劳是对二哥莫大的鼓励。谢谢!
文章转载自公众号:二哥聊云原生