异步编程模型
Javascript 的一个核心需求就是异步编程,这是因为 js 一般是在浏览器中运行,这种场景下存在很多依赖于网络 IO(比如说 fetch)的逻辑,如果不将逻辑处理成异步的,那么这些等待逻辑就会阻塞住整体程序。
那么异步编程的模型本质是什么?我觉得就是 Dependency DAG 的构建。在异步编程中,一行行代码不再是顺序同步执行的了,但是逻辑上的依赖关系依然存在,所以我们就需要用依赖图(DAG)去描述代码的执行顺序。存在依赖的节点要按序执行,不存在依赖的节点可以并发执行。
虽然编程模型的本质是很确定的,实现异步编程的方式则是多种多样的,习惯了 C 和 Linux 开发的我,习惯了 Process 和线程的概念,当然用它们区进行异步编程是可行的,但是这种方式不但会实现异步,还有可能会产生并行(也就是数据竞争之类的问题),这就导致不得不在异步编程模型中引入 lock 等工具,这就很不方便。
因为 JS 并没有引入线程来实现异步编程,而是采用 “event loop + single thread” 的思路来实现异步。具体来说,就是 JS 程序本身是单线程的,并不会出现“同时执行两段代码”的情况,但是它并不是严格按照顺序执行代码,而是从一个事件队列(event loop)中取出一段代码,检查它的依赖是否满足,如果满足就运行,运行一段时间后就主动结束,放入事件队列中,取下一个事件。在某种意义上,这是一种类似于协程的思路,在一个线程中执行,代码会主动让出执行的主动权。
JS 的这种使用设计导致了两个语法上的特性:一个是将函数视为一等公民,并且有各种方法去定义(箭头函数,lambda 函数)和使用函数(callback、函数参数、函数变量)。这是因为函数就是一个“可以异步的计算 event”,正因为 JS 很喜欢操作事件,所以它对于函数的操作就非常灵活。从某种意义上说,定义一个函数,就是在定义 DAG 图上的一个点(不精准,后面会看到 event 的粒度是比函数要小的)。
另一个特性就是对于 DAG 图上边的描述,也就是事件依赖关系的描述。在这一点上 JS 经过了多个时期的发展(也是我们这篇文章的主要内容)。我们在思考这些特性的时候,可以用“生产者-消费者”来去理解,一个依赖需要由生产者来完成,完成后就可以调用消费者了,它们是依赖边的起始点和终止点。
- callback hell:采用回调函数的方式来描述依赖,消费者是一个 callback 函数,生产者将其作为一个函数参数,在完成依赖以后,再调用生产者。这种问题就在于,级联的依赖会出现嵌套的传参过程(每个依赖关系都需要用传参描述),非常复杂。
- promise: 生产者立刻返回一个 promise ,供消费者消费。而实际上只有当生产者真正返回时,promise 才会 notify 消费者,让消费者运行。这种 promise 很像是依赖边本身的抽象,它接受生产者的结果,并传递给消费者。
async/await: 这组语法是建立在 promise 之上的语法,原本的 promise 是需要显式创建的,也就是依赖关系需要显式指定。而async/await语法的引入,使得程序员可以用代码本身的“同步性”(出现在前一行的语句默认先于后一行语句执行,所以往往靠前出现的是生产者,靠后出现的),来隐式的描述依赖。
Callback Hell
我们从最简单的开始,我们有一个函数 A ,它会返回一个 return_a ,这个值会被 B 函数利用。然后 A 函数内部有一个等待操作:
function A() {
const return_a = wait_so_long(); // block here
}
a = A();
B(a);
do_other_thing();我们希望 wait_so_long() 这个函数不要再占据 CPU 了,让 CPU 去做些别的事情,比如说去执行 do_other_thing() ,类似这种:
function A() {
const return_a = wait_so_long_and_yield(); // wait and yield
}
A();
do_other_thing(); // CPU can do this thing.但是这种方式就会发现,在等待结束后,才能执行 B 函数,但是 B 并不知道啥时候等待会结束,所以为了让 B 能在 return_a 生成后,立刻知晓,我们需要把 B 传入 A 。
function A(callback) {
const return_a = wait_so_long_and_yield(); // wait and yield
callback(return_a); // callback `B`.
}
A(return_a => {
B(return_a);
});
do_other_thing(); // CPU can do this thing.可以看到我们通过传入 callback 函数的方式构建了依赖,消费者就是 callback 函数,被作为生产者的函数参数传入。核心在于这一段:
return_a => {
B(return_a);
}这里指定了 return_a 是某种“消费者”,而 B 是消费者。至于为啥不直接用 A(B) 呢?在这个例子中还是可以的,但是在后面的例子中会发现很复杂。
但是如果有一个逻辑是 A -> B -> C 呢,就会变得更加复杂:
function A(callback_b) {
return_a = wait_so_long_and_yield(); // wait and yield
callback_b(return_a); // callback `B`.
}
function B(a, callback_c) {
return_b = wait_so_long_and_yield(a); // wait and yield
callback_c(return_b); // callback `C`.
}
function C(b) {
console.log(b);
}
A(return_a => {
B(return_a, return_b => {
C(return_b);
});
});比较复杂的是:
B(return_a, return_b => { ... });这段代码可以看出,return_a 是 B 的生产者是 A ,而 return_b 说明了 B 是 C 的消费者。
但是也可以看到,调用链已经非常复杂了,复杂到让人不想看了。这就是所谓的“回调地狱(Callback Hell)”。
Promise
为了解决上述问题,人们引入了 Promise 的概念。我们还是从 A -> B 说起:
function A() {
return new Promise(resolve => {
const return_a = wait_so_long_and_yield();
resolve(return_a);
});
}
function B(a) {
console.log(a);
}
A()
.then(B);
do_other_thing(); // CPU can do this thing.可以看到 A 基本上就是产生了一个 Promise ,这个 Promise 的构造,是传入了一个函数,这个函数基本上就是原本 A 要做的事情,但是有两点不同:
- 参数是
resolve:这是Promise构造器中提供的一个函数,它可以看作是一种带有 notify 机制的return。 - 返回不用
return了,而是用resolve:其实和上一点保持一致,不再用return,而是用resolve。
这里插一些闲话,我觉得这种“在原有函数外面再包一层函数”的方法很 JS ,在
Promise的构造中,相当于包了一个resolve为函数参数的 wrapper 函数,这种方式很神秘。另外
resolve之所以要作为一个函数参数,而不是一个 global 的函数,比如说Promise.resolve,这是因为resolve是不能够随便被调用的,而是只能在 Promise 的构造中使用,所以作为了函数参数。
那么 then() 的作用是什么呢?,它是 Promise 的一个方法,当真正 resolve 的时候,它会接受这个 return 的值,然后传递给它的参数,也就是那个消费者,在上面这个例子中,就是 B ,可以看到它消费了 a 。
接下来我们来看 A -> B -> C 版本:
function A() {
return new Promise(resolve => {
const return_a = wait_so_long_and_yield();
resolve(return_a);
});
}
function B(a) {
return new Promise(resolve => {
const return_b = wait_so_long_yield(a);
resolve(return_b);
});
}
function C(b) {
console.log(b);
}
A()
.then(B)
.then(C);
do_other_thing(); // CPU can do this thing.可以看到,对于级联的依赖,这次不再有嵌套了,而是变成链式的了。
另外说一下,可以看到
B的实现很有趣,因为它和A不同,它原本是有参数a的,但是我们又规定了Promise传入的函数的参数一定要是reject,然后就发现a没地方传了,最终的解决方式是a作为闭包的一部分使用
Async/Await
最后我们迎来最简单的版本:
async function A() {
return wait_so_long_and_yield();
}
async function B(a) {
return wait_so_long_yield(a);
}
function C(b) {
console.log(b);
}
async function main() {
const a = await A();
const b = await B(a);
C(b);
}
main();
do_other_thing(); // CPU can do this thing.简单说,用 async 声明的函数,他们就是会 return 一个 Promise 。而且他们是可以被 await 的,简单说,await 就是另一种形式的 then,当你 await 一个 async 函数的时候,其实你就是在等待这个 Promise 结束后给你返回值, await 会把 Promise 中 resolve 的值剥离出来。
在 await 的时候,可以让出 CPU ,让 CPU 去 do_other_thing 。
那么这种方式比 Promise 的好处在哪里,那就是不再需要显示的区声明 Promise ,而且也不需要显式的构建依赖关系了,用 await 出现的顺序就好了。
多源依赖
之前我们一直说的是级联的,或者说是线性的依赖,那么我们如何处理同一个函数,依赖多个异步事件的情况呢?答案是使用 Promise.all() 来描述:
async function main() {
// 两个独立的 source
const a = A();
const b = B();
// C 依赖 A 和 B
const c = Promise.all([a, b])
.then(([ra, rb]) => C(ra, rb));
}可以看到 async/await 语法就没法在这里大展拳脚了,这也是因为它们更适合描述线性控制流。