《你不知道的JavaScript(中)》阅读笔记 —— 异步

上个月看完了《你不知道的 JavaScript(上)》,上册分为两部分,第一部分讲了作用域与闭包以及 this 的指代问题,第二部分则讲了 js 的面向对象,重点分析了原型链。收获很大。

最近我继续读了中册,中册同样是两部分。但是第一部分讲 js 的类型和语法,内容有些繁琐,由于事情比较多,不太有心境看进去。。。而第二部分讲异步,这一部分我还是比较感兴趣的。

这学期操作系统课上对进程线程以及线程间的同步互斥的讲解,让我对于之前不太能理解的 js 的异步有了很大的好奇,想要去一探究竟。

写在前面

《你不知道的 JavaScript(中)》的作者在同步和性能这一部分中,从异步的概念谈起,聊了 js 的单线程和并发、任务等概念,这里的逻辑和操作系统的一致的。

接着作者开始着手介绍回调函数,或者说着手介绍回调函数的缺点。并由此引出了 Promise。在对 Promise 进行深入讲解之后,作者又讲了 generator 生成器在异步中的作用。

众所周知,js 的异步经历了如下历史:

回调函数 -> promise -> generator -> async/await

第一次读到本书的 promise 部分的时候,是有一些吃力的,因为我对于 promise 的概念其实没有一个很深的理解,只知道.then 而已。于是在此之前,我首先阅读了阮一峰老师的ECMAScript6 入门的 Promise 部分。在我熟悉了 promise 之后,再来阅读《你不知道的 JavaScript(中)》就能够读懂作者所要传递的意思了。

1、单线程与异步任务

我之前很长一段时间里都对“异步任务”这个概念有着很大的误解。同时对于“js 是单线程的”这句话有着很大的误解。

js 是单线程的

我们先来解释 js 的单线程是什么意思。

js 是单线程的意思指的是,浏览器中运行 js 的引擎是单线程的。这里运行 js 的引擎也叫 JS 内核,比如 V8 引擎,其作用就是负责处理执行 JavaScript 脚本程序。

但是这不意味浏览器也是单线程的,不是说所有的任务都由 js 这个单线程来完成。(笔者曾经的困惑就是误认为 js 是单线程指的是浏览器的行为是单线程的)

比如说 GUI 渲染线程就负责渲染。我们听人常说,js 线程和 GUI 渲染线程是互斥的,因为 js 会改变 dom 元素。

所以我们首先要搞清楚,浏览器完成它的任务靠的是很多个线程共同完成。而 js 的单线程的意思就是说对于一个 tab 页,一个时刻只有一个线程在执行 js 脚本。

异步任务

接着我们再来讨论”异步任务”

这里我就不讨论大家都知道的为什么要有异步任务了。我想阐明的是,我们的异步任务到底是哪个线程来完成的,以及它和 js 单线程的关系。

举一个定时器的例子,(其实我很不想用这个例子,因为就是这个例子曾经让我很困惑)

1
2
3
setTimeout(function callback() {
console.log("我才是异步任务!");
}, 1000);

就是这个很简单的例子,让我一度十分迷惑。因为我以前认为setTimeout()这个函数本身是异步任务,而事实上,函数callback()才是我们这里说的异步任务。

换个网络请求的例子。设想一个场景,我们要向服务器发送一个请求,获取一个值data,并且将它输出。这个用代码实现很简单,比如通过 Ajax 操作来进行请求,那么我们在回调函数里放一个console.log()即可。

那么在这个例子里,什么是异步任务呢?没错,是输出data这个任务,而不是发送网络请求这件事情。

如果你没有像我这样的误解,那么恭喜你,你从一开始理解的异步任务就是正确的,如果你也曾像我一样困惑,那么以后你将对异步任务有着更深更正确的理解。

到这里,我们回顾一下异步任务和同步任务比较教条的概念。

  • 同步任务: 同步任务是那些没有被引擎挂起、在主线程上排队执行的任务。只有前一个任务执行完毕,才能执行后一个任务。
  • 异步任务是那些被引擎放在一边,不进入主线程、而进入任务队列的任务。只有引擎认为某个异步任务可以执行了(比如 Ajax 操作从服务器得到了结果),该任务(采用回调函数的形式)才会进入主线程执行。排在异步任务后面的代码,不用等待异步任务结束会马上运行,也就是说,异步任务不具有“堵塞”效应。

结合上面的例子,setTimeout例子中,我们的异步任务就是那个console.log("我才是异步任务!"),它不会立即执行,它会在未来某个时刻(2s 以后)“被引擎认为该执行了”。在网络请求的例子里,异步任务则是 console.log(data),它不会立即执行,它会在网络请求有响应以后,“被认为可以执行”

同样,我们听别人说的事件循环,循环的事件也是这些异步任务。上面的例子里,“被认为可以执行”以后,就会被放进事件队列里。

那么问题来了,之前例子里,计时 2s 和发送网络请求这两个任务谁来完成呢?要是单线程的话,它们什么时候完成?

这就是我之前困惑的地方。答案其实很简单,这两个任务由其他线程来完成。计时 2s 由 timer 线程完成,发送网络请求由负责 network 的线程来完成。

希望到这里,所有人都不会对异步任务和单线程有误解。

2、任务队列与回调函数

单线程的好处

我们可以考虑一下并行线程的行为。

1
2
3
4
5
6
7
var a = 20;
function foo() {
a = a + 1;
}
function bar() {
a = a * 2;
}

假设上述代码的 foo 和 bar 函数分别由两个线程并行执行,那么我们很容易想到,a 最终的结果是不可预测的,因为这些指令的先后顺序没办法确定。(类比操作系统中多线程的同步互斥)

而我们 js 是单线程的,我们总是可以保证一个函数完整执行,只要函数间的顺序决定好,结果就是可预测的。

事件队列

单线程处理异步任务的方式通常都是回调函数。js 处理回调函数的方式是任务队列。(请再次搞清楚,任务队列里的任务是异步任务,不是发起异步任务的东西)

js 引擎执行代码时有三个数据结构,一个是栈,一个是堆,一个是队列。

栈用来运行代码,堆用来保存对象等数据(这一点和 Java 等语言是一致的),队列则是任务队列。

js 引擎每次在栈中新建一帧,这一帧就是一个函数的运行环境。(我们可以假设所有的代码最外层包裹着一个函数)

在当前这一帧中,如果我们 new 了一个对象,就在堆里增加其数据的保存。如果我们由调用了新的函数,就在栈里再 push 一帧,如果它调用完了,就弹出。以此类推。

而在我们上面运行的过程中遇到异步任务,就把它交由对应的线程来处理,并且在满足条件时被对应的线程插入到事件队列中。

当栈空时,我们就循环一次事件队列,把它加入栈中,依次类推。

这个过程和 Java 等编译语言也是类似,只需要加入事件队列这一结构即可。并且不断的进行事件队列的循环。

回调函数的信任问题

回调函数的使用方法作者没有过多的阐述,它更多的是讲了回调函数的缺点。

提到回调函数的缺点,所有人都能想到回调地狱,以及其非直觉的代码顺序。但是相比与老生常谈的回调地狱问题,作者更多的笔触在讲解回调函数的另一个很严重的缺点:那就是信任问题。

我们回顾回调函数的使用,以 ajax 为例吧。

1
2
3
ajax("..", function() {
// C
});

我们给 ajax 传入一个回调函数,并且期望它在应该的时候被调用。

回想我们之前说过的问题,ajax 这个行为是谁来做的,回调函数何时被调用是由谁来通知的?

没错,是别人,不是你自己。

那你怎么能保证你的回调函数被调用了呢,甚至你怎么能保证你的回调函数被正确的(在正确的时机,执行了正确的次数等等)调用了呢?

我们发现我们使用回调函数的时候,把控制权交了出去,这叫控制反转。而控制反转产生了信任问题。因为你和你使用的 API 并没有明确的契约。

回调函数可能出错的方式:

  • 调用回调过早(在追踪之前)
  • 调用回调过晚(或没有调用);
  • 调用回调的次数太少或太多(就像你遇到过的问题!);
  • 没有把所需的环境/ 参数成功传给你的回调函数;
  • 吞掉可能出现的错误或异常;

Zalgo

还是之前的例子,我们有一个更加疯狂的怀疑,这个怀疑是合理的。

1
2
3
4
ajax("..", function() {
console.log("A");
});
console.log("B");

这个代码的输出顺序是什么?你会毫不犹豫的说是 B - A!

你确定吗?

我换个方式问,你确定你使用的 API,一定会异步执行你的任务吗?

我们没办法确定。

而这种因为异步和同步任务的不确定性而导致的代码结果的不确定性,我们称之为 Zalgo

解决 Zalgo 的方式是在外面包裹一个 setTimeout(..,0)来确保任务被异步执行。

3、promise

关于 promise 的细节,建议去看阮一峰老师的教程,这里只是谈 promise 的理念,建立在对于 promise 有个基本概念的基础上。

promise 的理念

铺垫了这么多,promise 的概念应运而生。

顾名思义,promise 就是承诺的意思。它的本质就是由 api 提供一个承诺给我们,通过承诺来给告诉我们异步任务是否可以开始进行了,由我们来调用异步任务。

我借用作者的例子,并且稍加改动。

假如我们在肯德基就餐,我们想吃汉堡,很明显这是个异步任务(因为汉堡的制作需要时间)。我们怎么发起这个异步任务呢?那就是向餐馆点餐。(类似于我们调用了 ajax 来要求 network 线程发送网络请求)

如果是回调函数模式: 我们点餐之后回到座位上等待。肯德基做好汉堡之后,让服务员将汉堡送到我们桌子上(加入事件队列),我们拿到汉堡开始就餐(执行异步任务)。这里就有一个信任问题,要是店铺不给我们汉堡(没有执行回调函数),那我们就会一直空腹等待。

现在 Promise 的理念是这样的: 我们点餐以后跟吧台要了一个电子凭据。这个电子评据可以显示我们的汉堡现在的状态,是正在制作,还是已经做完了,或者运气比较差,汉堡卖完了。我们可以根据这个电子评据来决定我们的行为: 汉堡做好了,我们去取餐(执行异步任务)

如果你之前在项目中使用过回调函数和 promise,那么你类比上面的例子就会发现,这个电子评据就是 promise。

这种理念让控制再次反转了回来,我们不把回调函数给 API,要求 API 调用。而是我们要求 API 提供给我们一个 promise,通过 promise 来告诉我们它提供的服务的状态,我们自己根据状态来执行异步任务。

promise 的信任问题

而这个理念最大的作用不是提供给我们链式调用 then 来避免回调地狱。而是提供给我们解决信任问题的方式。promise 就是解决了之前说的一系列信任问题。

调用过早

promise 通过规则来避免 Zalgo:那就是把所有操作都异步处理(微任务队列),尽管它可以同步完成。

1
2
3
4
5
6
new Promise(function(resolve) {
resolve(2);
}).then(v => {
console.log(v);
});
console.log(1);

这里的代码执行结果是 1 - 2,上面的 Promise 中,立即执行了 resolve,但是它的回调还是异步执行了。

调用过晚

1
2
3
4
5
6
7
8
9
10
p.then(function() {
p.then(function() {
console.log("C");
});
console.log("A");
});
p.then(function() {
console.log("B");
});
// A B C

上面的例子中,p 是一个 promise 对象。C 被放入了下一个异步队列,不会抢占 B。也就是说,一个 Promise 决议后,这个 Promise 上所有的通过 then(..) 注册的回调都会在下一个异步时机点上依次被立即调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let p3 = new Promise(function(resolve, reject) {
resolve("B");
});
let p1 = new Promise(function(resolve, reject) {
resolve(p3);
});
let p2 = new Promise(function(resolve, reject) {
resolve("A");
});
p1.then(function(v) {
console.log(v);
});
p2.then(function(v) {
console.log(v);
});

这个例子的结果是 A - B 这里的规则是 p1 resolve 了一个 promise 对象,那么它会异步展开它并且根据后者的决议状态进行决议。

我们具体分析代码,p3 立即 resolve 了’B’。

  • p1 中 resolve 了 p3,那么这个时候它异步展开,那么 p1 就还是 pending,因为异步的意思就是把展开这个行为放在在微任务队列的末尾。
  • p2 立即 resolve 了’A’.
  • p1.then 订阅回调函数,但是 p1 还是 pending,不执行。
  • p2.then 订阅回调函数,p2 已经 resolve,立即执行。
  • 开始微任务队列循环,p1 展开 p3, p3 resolve’B’,p1 也 resolve ‘B’,回调函数加入微任务队列
  • 执行回调函数

回调未调用

其一我们有 reject 回调。

如果 promise 永远是 pending 的话,我们可以用Promise.race()来加入一个定时异步任务,这样来防止超时。

调用次数过多或过少

promise 只能被决议一次。如果调用 resolve 多次,只接受第一次决议。

吞掉异常或者错误

promise 会把异常在 then() 链中传递。(因为默认的 onReject 函数就是接受错误并且传递错误)

Promise API

Promise.resolve() 把参数包装成 promise。如果参数是 promise,直接传递;如果参数是 thenable 对象,则将其转为 promise 并且立即执行 then()方法;如果参数是其他的,就返回新的 promise 对象,状态为 resolved

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let thenable = {
then: function(resolve, reject) {
resolve(42);
}
};

let p1 = Promise.resolve(thenable);
p1.then(function(value) {
console.log(value); // 42
});

const p = Promise.resolve("Hello");

p.then(function(s) {
console.log(s);
});
// Hello

Promise.all() 接收一个 promise 数组,所有的 promise 对象都 resolve 的话,它 resolve 一个数组。只要有一个 reject,它就 reject。空数组传进去会立即完成

Promise.race() 接收一个 promise 数组,一旦有一个 promise 对象决议,它就决议。空数组传进去会永远不会决议

4、generator

Promise 有很多的.then,这在视觉上还是不够优雅。

协程

协程,又称微线程,纤程。英文名 Coroutine。

子程序,或者称为函数,在所有语言中都是层级调用,比如 A 调用 B,B 在执行过程中又调用了 C,C 执行完毕返回,B 执行完毕返回,最后是 A 执行完毕。

所以子程序调用是通过栈实现的,一个线程就是执行一个子程序。

子程序调用总是一个入口,一次返回,调用顺序是明确的。而协程的调用和子程序不同。

协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。

注意,在一个子程序中中断,去执行其他子程序,不是函数调用,有点类似 CPU 的中断。

generator 和 Iterator

生成器 generator 就是协程的一种实现。它允许我们在函数执行过程中进行上下文的切换。

generator 的语法和运行过程不赘述,详情可以看阮一峰老师的 es6 教程。

我们调用 generator 函数,会返回一个迭代器,使用这个迭代器可以操控生成器的执行。

我们还可以换个视角来看待 generator 和 iterator 的关系,从数据传递的角度。

迭代器和生成器互相提问。首先迭代器用 next()来向生成器提问:“你给我什么值?”,然后迭代器运行到 yield,通过 yield 的参数回答这个问题,把结果给迭代器。与此同时,生成器也向迭代器提了问题,“你要给我什么值”。迭代器通过下次 next 携带参数传递给生成器,生成器通过 yield 的返回值获取答案。以此类推。

因此 next 的数量往往是大于 yield 的。

generator 与异步

所谓异步就是这个任务不是连续的,被分为两部分,第一部分是现在执行,第二部分则是未来执行。

比如之前的网络请求,第一部分就是通知 network 线程发送网络请求,第二部分就是拿到请求的结果。

这和生成器有着很自然的适配。我们在生成器中执行第一部分任务,然后暂停,把线程的控制权交给别的任务继续同步执行,然后等待第二部分的任务可以执行时,再拿回控制权,继续执行。

generator 和 iterator 的信息交互帮助我们很容易能够实现这样的过程。

最重要的是在生成器内部,除了 yield 之外,代码的组织方式很像同步的组织方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let it;
function* asyncTask() {
const data = yield myAjax("www.baidu.com"); //开始执行第一部分的任务
console.log(data); //执行第二部分的任务,但是看上去像同步,其实是异步的。
}
function myAjax(url) {
ajax(url, (err, data) => {
if (err) {
} else {
it.next(data); //拿到数据后,再次把控制权转给生成器
}
});
}
it = asyncTask();
it.next(); //开始执行
console.log(123);

上面这个例子中,我们用生成器作为一个异步任务的“容器”,它的内部可以用同步的代码组织形式来组织代码。

基于 thunk 函数的流程控制

但是我们发现上面这个例子多写了很多多余的代码来进行异步任务的流程控制,但是它们是可以复用的,我们可以写一个自动执行器。

在此之前,我们先介绍一下 thunk 函数,这个概念和函数柯里化(可以参考我之前的博客:this 与函数柯里化)是类似的,

thunk 函数就是把一个函数柯里化,只不过 thunk 函数总是把一个函数的参数分两次传入,其中第二次传入的是它的回调函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
// es5
function thunk(fn) {
return function() {
const args = Array.from(arguments);
return function(callback) {
args.push(callback);
return fn.apply(this, args);
};
};
}

//es6
const thunk = fn => (...args) => callback => fn.call(this, ...args, callback);

利用这个 thunk 函数,我们重新之前的 ajax 的例子

1
2
3
4
5
6
7
8
9
ajaxThunk = thunk(ajax);
function* asyncTask() {
const data = yield ajaxThunk("www.baidu.com");
console.log(data);
}

const it = asyncTask();
const next = it.next();
next.value(data => it.next(data));

这里重点观察next.value的含义,由于我们 yield 的是一个 thunk 函数,这个 next.value 就是提供给外界一个函数,它接收一个回调函数。

利用 thunk 函数我们可以自动实现流程控制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const run = gen => {
const g = gen();
const next = data => {
let result = g.next(data); //执行生成器中的任务
if (result.done) return result.value;
result.value(next); //把next作为回调函数传给thunk函数,实现自动流程控制
};
next();
};

function* asyncTasks() {
const data = yield ajaxThunk("www.baidu.com");
console.log(data);
const data1 = yield ajaxThunk(data.url);
console.log(data1);
}

run(asyncTasks);

基于 promise 的流程控制

在上面的例子里,我们 yield 了一个 thunk 函数,其他流程拿到这个函数之后要做的仅仅是给他传一个回调函数。

相信你会觉得这种行为似曾相识,没错我们的 promise 的.then()就是传一个回调函数。

因此我们自然而然地会想试试 promise 与 generator 的结合。

1
2
3
4
5
6
7
8
9
//request api返回一个promise对象

function* asyncTask() {
const data = yield request("wwww.baidu.com");
console.log(data);
}

const it = asyncTask();
it.next().value.then(data => it.next(data));

和上面 thunk 函数的代码几乎是一模一样的,只不过我们 yield 了一个 promise 对象,并且把回调函数传递给 then()方法。并且这种模式要比 thunk 函数更容易理解。

同样的 promise 的自动流程控制如下

1
2
3
4
5
6
7
8
9
10
11
const run = gen => {
const g = gen();
const next = data => {
let result = g.next(data);
if (result.done) return result.value;
result.value.then(data => {
next(data);
});
next();
};
};

5、 async/await

终于到了我们现在习以为常的async函数。

其实async函数就是上面generator+promise的语法糖。把 星号 换成async, yield换成await。我们不用自己写流程控制,async函数为我们写好了流程控制。

async的实现也就是使用我们上面分析过程中的实现方式,只不过它更加严谨。

6、 总结

js的异步解决方案已经从最初的回调函数发展到了现在的async函数。

但是正如糖吃多了也有害一样,我们不能只沉浸在async函数这种对回调函数层层封装提供的语法糖的喜悦之中。只有对它产生的意义和如何产生的搞清楚,我们才能够安心地使用这些。