异步编程是JavaScript中很重要的一个概念,这将极大提高前端工程的执行效率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const btn = document.querySelector('button');
btn.addEventListener('click', () => {
let myDate;
// 循环体
for(let i = 0; i < 10000000; i++) {
let date = new Date();
myDate = date
}

console.log(myDate);

let pElem = document.createElement('p');
pElem.textContent = 'This is a newly-added paragraph.';
document.body.appendChild(pElem);
});

在这个上例中,按钮的每一次点击时,依次执行循环体和剩余代码。我们于是会看到,在网页上点击按钮在很长一段时间后,才会“有反应”。我们实际上期望循环体不阻塞后续代码的执行。

我们可以使用web worker实现这个需求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const btn = document.querySelector('button');
const worker = new Worker('worker.js');

btn.addEventListener('click', () => {
worker.postMessage('Go!');

let pElem = document.createElement('p');
pElem.textContent = 'This is a newly-added paragraph.';
document.body.appendChild(pElem);
});

worker.onmessage = function(e) {
console.log(e.data);
}

主线程创建Worker线程执行原有的循环体,在日期计算结束后,再输出结果。也就是说,主线程和Worker线程是并行执行。

正如我们所见,异步、阻塞、并行这三个概念似乎紧密地捆绑在一起。但我们极少去区分什么是异步和同步、阻塞和不阻塞、并行和并发这三组概念。

在对这三组概念进行讨论之前,我们需要明确的是,异步和同步、阻塞和不阻塞、并发是对执行模型的特性的描述,而并行是一种具体的执行模型。

控制流

要理解异步编程,我们首先需要了解计算机是如何执行一段程序的。学过计算机组成原理和操作系统的同学都应该清楚,计算机执行一段程序的过程,无非是不断地取指和执行,我们把这个过程叫做指令周期。

在CPU上,有一个称作程序计数器(Program Counter,PC)的寄存器,存储指令的地址。我们假设PC中存储值的序列为:
$$a_0,a_1,…,a_{n-1}$$
其中$a_k$是指某个相应指令$I_k$的地址。我们把从$a_k$到$a_{k+1}$的过渡称为控制转移(control transfer),所谓控制流就是指这样的控制转移序列。

如果对于所有的$I_k$和$I_{k+1}$都在内存中连续,则称这样的控制流为平滑控制流。如果存在$I_k$和$I_{k+1}$并不连续,则把这种不连续的控制转移,称为突变。相应地,我们把这些突变称作异常控制流。需要注意的是,这里的异常并不是指编程语言中的异常(Exception)。
诸如函数调用、中断等都会导致异常:

1
2
3
4
5
6
function a(){
console.log(1)
}
console.log(2) // addr b
a() // 跳转到函数 a 的入口地址执行
console.log(3) // addr c

可以看到这里调用了函数a,产生了控制流的突变。而对于中断,是由外围设备发出中断请求,然后CPU在每次的执行周期末尾,通过检查中断寄存器,感知是否存在中断,并且进行相应处理。

我们可以发现,其实函数调用(具有同步性)和中断(具有异步性)是很相似的,区别就在于:中断产生的异常可以在任意时刻发生,而同步异常(如函数调用)总是在可期望的时刻发生。通常,在很多技术书籍和博客中对异步的介绍是:异步是指代码的实际执行顺序与书写顺序不一致。但这其实是一个含糊不清的阐述,究竟什么是执行顺序,究竟什么又叫书写顺序?函数调用算不算执行顺序与书写顺序不一致?

通过对控制流的介绍,我们了解到,所谓异步就是指控制流发生突变的时刻是不可预期的特性。从这里我们可以看出,异步和非阻塞、多线程、并行等在概念上并不存在直接联系。

异步与非阻塞

在上一节我们了解到,异步仅仅是指控制流发生突变的时刻是不可预期的。但通常,在很多技术资料包括MDN中,都直接告诉我们,异步可以实现非阻塞。这又是怎么回事呢?

我们先了解阻塞和非阻塞。

可以看到,阻塞和非阻塞的区别在于:调用者发起请求后,是否一定需要等到被调用者完成作业后才执行后续任务。

在计算机组成原理中,我们学习过两种典型的I/O信息交换方式:中断驱动和程序控制。这两种方式,就分别体现了非阻塞和阻塞的特性。

中断驱动方式可以在发送请求给I/O模块后立即返回,然后继续做其他的事情,而不需要像程序控制方式不断读取I/O模块的状态。这避免了忙等,加速了程序执行的效率。但是需要注意的是,中断也并非不需要轮询。在上文中,我们可以看到,在指令周期的末尾,CPU会轮询地检查中断寄存器。但这种检查,并不会像程序控制方式轮询地检查I/O模块的状态一样,阻塞其他任务的执行。

回顾上文所讲的同步和异步,可以发现,中断作为一种实际模型,是满足了异步性和非阻塞性两种特性。但并不是所有满足异步的模型都是非阻塞的。例如,我们可以再发送请求后一直等到中断请求的到达而其中不去处理其他任何任务,这就是异步阻塞的。不过在很多场景下,异步阻塞是无意义的,因此,在很多技术书籍中,异步与非阻塞通常是捆绑在一起介绍。

异步与多线程和并行

通常,异步也和并行、多线程这两个概念共同出现。在操作系统中,我们说,并行是指在一个时间上,有多个任务同时执行;而并发是指,在一个时间段内,有多个任务同时执行。

Erlang 之父 Joe Armstrong用一组很简明的图画阐述了并行和并发。

Shop A的策略就是并发,一个商店可以同时间段内服务多组人(多个任务);而Shop B的策略是并行,Shop B开启了两个窗口(线程),每个窗口服务一组人。

但是需要注意的是,我们其实并不知道Shop A具体是如何做到同时间段内服务多组人的。Shop A可以像Shop B一样,开启多个窗口;也可以用时间片轮转的方法,每服务一组人一段时间,就服务另一组人一段时间。所以,并行其实是并发的特例。

我们回到控制流。这里的两组人,是不是就相当于两个控制流?对并发更准确的解释,即在一段时间内,处理机有多个控制流同时执行

JavaScript中的异步编程模型

在上文中,我们可以发现,并行和并发、异步和同步、阻塞和非阻塞并不一定有天然的不可分割的关系。但在JavaScript中,这几个概念往往混杂在一起,这是因为JavaScript对异步编程模型的实现,同时体现了并发、异步、非阻塞的特点。下面,我们就详细介绍一下JavaScript中异步编程模型的机制。

异步编程模型的机制

在很多技术资料中都会提到,JavaScript是单线程语言。虽然这个描述并不十分准确,但的确能反映大部分的事实。JavaScript的确只有一个主线程,大部分任务都在主线程中执行。

在JavaScript中,基于事件循环构建了异步模型。

在主线程执行完毕后,执行当前所有的微任务,接着再从宏任务中取event,推入主线程执行。在主线程中,会发生诸多耗时持久的请求,如请求网络资源。这时,往往会将其作为异步事件处理。触发异步事件,会将任务推入I/O线程中执行,并在异步事件执行完毕时,把任务推入消息队列中。

事件循环则会不断检查消息队列中是否存在任务需要处理。所谓事件循环,就是指这样的一种执行策略:

1
2
3
while (queue.waitForMessage()) {
queue.processNextMessage();
}

我们其实可以发现,这里的设计和中断的设计存在异曲同工之妙。中断机制中,对中断寄存器的检测,就类似此处对消息队列的检测。

基于事件循环的异步模型,很好的解决了程序设计中对异步、非阻塞和并发的需求,同时,规避了对锁等概念的引入。

这里对宏任务和微任务的划分,和多级反馈队列调度算法很相似。在所有微任务执行完毕之前,下一个宏任务并不会马上执行。宏任务主要包括了script、setTimeout、setInterval、I/O、渲染等浏览器提供的功能,而微任务主要包括了Promise等JavaScript引擎提供的功能。

之所以会划分宏任务与微任务,是为了提供一种优先级调度的功能,这样做很好地兼顾了效率和实时性。

Callback 与 Promises

在本节,我们将了解JavaScript中两种异步编程语法。

回调函数 callback

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function loadAsset(url, type, callback) {
let xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = type;

xhr.onload = function() {
callback(xhr.response);
};

xhr.send();
}

function displayImage(blob) {
let objectURL = URL.createObjectURL(blob);

let image = document.createElement('img');
image.src = objectURL;
document.body.appendChild(image);
}

loadAsset('coffee.jpg', 'blob', displayImage);

在这段代码中,我们可以看到callback是如何工作的。displayImage函数作为loadAsset函数的参数。在调用loadAsset函数时,会触发异步事件,于是Ajax请求送入I/O线程进行作业。在Ajax请求完毕时,调用回调函数。

于是,在功能上,我们使得displayImage异步执行,不再需要频繁检测请求是否完毕。

当然,并不是所有的回调函数都是异步的。

1
2
3
4
5
const gods = ['Apollo', 'Artemis', 'Ares', 'Zeus'];

gods.forEach(function (eachName, index){
console.log(index + '. ' + eachName);
});

在这个例子中,回调函数同步执行。这是因为这段代码并不触发任何的异步事件,也不会移入I/O线程中进行执行。

实际上,基于之前对异步和JavaScript事件模型的介绍,我们可以发现,JavaScript中的异步是关于两个线程之间的特性。在I/O线程内部,任务依旧是同步执行的。但对主线程而言,I/O线程的执行是不可预期的。

Promise

回调函数是最容易实现的异步编程范式,但很容易产生回调地狱。例如这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 第一个任务
function task1 (callback) {
setTimeout(() => {
console.log('1', '我是第一个任务,必须第一个执行');
callback && callback(1);
}, 3000);
}

// 第二个任务
function task2 (callback) {
setTimeout(() => {
console.log('2', '我是第二个任务');
callback && callback(2);
}, 1000);
}

// 第三个任务
function task3 (callback) {
setTimeout(() => {
console.log('3', '我是第三个任务');
callback && callback(3);
}, 1000);
}

// 所有任务
function allTasks () {
task1((cb1) => {
if (cb1) {
task2((cb2) => {
if (cb2) {
task3((cb3) => {
if (cb3) {
// 顺序完成所有任务
}
})
}
});
}
});
}

allTasks();

/**
* 3秒后
* 1 我是第一个任务,必须第一个执行
* 1秒后
* 2 第二个任务
* 1秒后
* 3 第三个任务
*/

这给我们的开发造成了很大的不便。

ES6提供了新的异步范式:Promises。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
new Promise(resolve => {
setTimeout(() => {
console.log('1', '我是第一个任务,必须第一个执行');
resolve(1);
}, 3000);
}).then((val) => {

new Promise(resolve => {
setTimeout(() => {
console.log('2', '我是第二个任务');
resolve(2);
}, 1000);
}).then(val => {
setTimeout(() => {
console.log('3', '我是第三个任务');
}, 1000);
});

});
/**
* 3秒后
* 1 我是第一个任务,必须第一个执行
* 1秒后
* 2 第二个任务
* 1秒后
* 3 第三个任务
*/

可以看到,这是一种链式写法,而回调是一种递归写法。我们只需要不断地调用then方法,即可约定任务间的执行顺序。

async和await

async和await是Promises的语法糖,它彻底把异步任务“同步”化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
/**
* 第一个任务
*/
function task1 () {
return new Promise(resolve => {
setTimeout(() => {
console.log('1', '我是第一个任务,必须第一个执行');
resolve('done');
}, 3000);
});
}

/**
* 第二个任务
*/
function task2 () {

return new Promise(resolve => {
setTimeout(() => {
console.log('2', '第二个任务');
resolve('done');
}, 1000)
});
}

/**
* 第三个任务
*/
function task3 () {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('3', '第三个任务');
reject('error');
}, 1000);
});
}

/**
* 第四个任务
*/
function task4 () {
return new Promise(resolve => {
setTimeout(() => {
console.log('4', '第四个任务');
resolve('done');
}, 2000);
})
}

/**
* 所有任务
*/
async function allTasks () {
await task1();
await task2();
await task3();
await task4();
}

// 执行任务
allTasks();

/**
* 3秒后
* 1 我是第一个任务,必须第一个执行
* 1秒后
* 2 第二个任务
* 1秒后
* 3 第三个任务
* Uncaught (in promise) error
*/