协程与异步模型:以JavaScript为例

协程与异步模型:以JavaScript为例

前言与基础概念

在现代计算机软件结构里,异步永远是绕不开的话题。无论是进行一些耗时操作,还是执行网络请求这种需要暂停一段时间才能继续的任务,都不可能允许单个线程阻塞的执行。否则,在该线程执行的期间,UI界面等其余所有的任务都会卡死。
因此,如何让程序员优雅的完成异步编程,就变成了架构师们的难题。

在此之前有必要简述几个概念:

  • 进程:操作系统控制应用程序的单位。任何应用程序都有且只有一个进程,操作系统会为每个进程分配一些独占的内存空间。
  • 线程:经由操作系统,交付给CPU执行指令序列的单位。一个进程可以创建多个线程,它们共享同一块内存空间,交替在CPU上执行任务;具体每个线程执行的时间则由操作系统控制。
  • 协程:一些编程语言或库中的概念,是物理串行(只会占据一个CPU处理)、逻辑并行(在程序员看起来是并发)的。内部指令执行的先后、顺序是由进程自己控制,与操作系统无关。

最早的VC是通过Windows API来调用系统库完成线程的构建的,需要操作句柄等,比较麻烦。C++和Java等经典的面向对象编程语言则是使用了线程对象+多线程阻塞操作的异步模型。
举例而言,在Java中,我们可以:

1
2
3
4
5
6
7
8
Thread th = new Thread(){
@Override
public void run(){
//做一些事情,例如执行网络请求
//这些事情可以阻塞线程直到事情结束,如网络请求,函数在收到响应体后才会返回
}
};
th.start();

为此,在大型的Java框架如Tomcat中都有线程池的概念,由框架维护一系列的线程并分配给具体的事务(如处理一个网络请求)。

这样做的缺点包括:

  1. 线程是基于系统的线程调度的,因此比较“重量”,线程开启、关闭等需要很多的额外开销,且线程的执行状态程序不直接可控。
  2. 线程共享同一块内存区域,因此在并发操作的情况下存在访问冲突的问题,需要加锁。

有过Java编程经验的人对锁肯定是又爱又恨,比如死锁之类的问题,这里就不再赘述了。
而JavaScript语言(这里及以下所有提到js的场合均指Node.js)则提供了一种全新的异步模型:单线程协程模型。

Node.js事件循环

整个js是始终单线程工作的(除了个别对系统api的调用),所有的函数都在一个线程下被依次的执行。但是,很显然实际当中有太多不能够立即完成的操作,这该怎么办呢?
为此,js内部提供了一个事件循环。形象地讲,js的代码始终是跑在一个while(true)的循环里的,而你写的所有代码则是从这个循环里被调用的。在每一轮循环里,node会遍历一个事件队列:它由一系列事件和注册在事件上的回调函数构成。
(注:本文所描述的是Node.js事件循环的一个高度简化的模型。实际上事件循环对不同种类的事件有复杂的调度策略,但在这里我们并不讨论;又由于回调函数和事件之间存在绑定关系,我们不区分事件和它的处理函数的区别,就认为事件循环是在一个一个的直接调用先前注册在其中的一系列函数即可。)
可以用下面的伪代码表示:

1
2
3
4
5
while(true){
for(handler in eventQueue){
handler();
}
}

而许多耗时操作,如网络请求、读写文件等,提供的API不再是同步阻塞线程式的,而是异步式的。
例如发送GET网络请求的API格式为:http.get(url[, options][, callback])。以下是Node.js官方文档给出的示例:

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
http.get('http://nodejs.cn/index.json', (res) => {
const { statusCode } = res;
const contentType = res.headers['content-type'];

let error;
if (statusCode !== 200) {
error = new Error('请求失败\n' +
`状态码: ${statusCode}`);
} else if (!/^application\/json/.test(contentType)) {
error = new Error('无效的 content-type.\n' +
`期望的是 application/json 但接收到的是 ${contentType}`);
}
if (error) {
console.error(error.message);
// 消费响应数据来释放内存。
res.resume();
return;
}

res.setEncoding('utf8');
let rawData = '';
res.on('data', (chunk) => { rawData += chunk; });
res.on('end', () => {
try {
const parsedData = JSON.parse(rawData);
console.log(parsedData);
} catch (e) {
console.error(e.message);
}
});
}).on('error', (e) => {
console.error(`出现错误: ${e.message}`);
});

我们注意到,上述定义的第三个参数是callback,需要我们传入一个函数。实际上,get方法本身并没有直接的发送并等待一个请求;当我们调用http.get方法时,它只是简单的在事件循环里注册一个函数表明我们希望发送一个请求,然后get函数就会立即返回
get函数和我们所在的函数全都返回,调用栈当前指针移动回事件循环之后,事件循环处理器会发现我们刚刚通过get方法添加的请求事件,然后通过操作系统的API将请求真正发送出去。
(注:js所谓的单线程指的是js逻辑的代码处理是单线程的,但是在事件循环管理、调用操作系统提供的接口方面,可能还是存在多线程的情况;但这些线程不会直接操作js主线程中定义的数据,因而对我们没有实际影响。)
然后,js就会继续处理事件循环中排队的其他函数去了。这样,事件循环一直运行下去……
直到网络请求回来了,那么系统库会在js的事件循环中加回一个表示网络请求完毕的事件。之后当事件循环处理器发现这个事件时,就会执行绑定在该事件上的处理函数,也就是我们在最初http.get方法中传入的第三个参数callback

往事件循环里添加函数有很多方法,其中为我们所熟知的是setTimeout(fn, ms);该方法执行的实际上就是往事件循环中添加一个事件,而当事件循环遍历到该事件时如果发现设定的时间未到则不会执行事件的处理函数,而是令其继续等待。这也告诉我们,js的setTimeout实际上不能保证准时性;举例而言,假如在计时器还剩5ms的时刻,事件循环执行了一个连续计算耗时10ms才返回的函数,那么这个计时器只有在连续耗时计算返回之后才有可能因为事件循环重新获取控制权而被调用,则时间势必已经晚了至少5ms。

总而言之,js原生的异步模型就是基于事件处理、回调函数的协程模型,任何函数都应该在可接受的时间内尽快的返回出来,然后在后台执行复杂操作,并在上述复杂操作结束后,以向事件队列加入一个回调函数的方式让后续的操作得以继续。每一个被回调的函数都相当于开启了一个小小的协程,它们由js集中管理;对操作系统而言,操作系统只知道js解释器在不停的调用各种指令,却无从知道这些指令由来于哪个地方。

对比上述Java多线程阻塞模型,我们可以得到js异步非阻塞模型的优点:

  1. 对操作系统而言始终是一个线程在切换不同的指令组而已,没有新建线程创建栈、预置缓存等开销,异步操作更“轻量”;
  2. 始终是单线程操作,不存在多个处理器同时访问数据或某个函数执行到一半控制权被转移的情况,也就自然不需要锁了;
  3. 程序员对异步操作的控制力更强。只要是连续写在一起的代码,能够保证被连续的执行,不会因中断造成错误;而希望转移控制权的地方可以主动交出控制权。

更优雅的异步编程:Promise与async/await

但是程序员们仍然认为,上面依靠回调函数的异步还不够优雅和简洁。为此,经过了广泛的讨论和大量的时间,js终于在其标准中加入了利器:Promiseasync/await
Promise是一种对异步回调模式的有效封装,一个Promise对象有三种状态:pending、resolved和rejected,和一个唯一的事件处理函数handlerhandler有两个函数类型的参数resolvereject
resolve被调用时,Promise的状态就会由pending变为resolved,反之当reject被调用时,Promise的状态就会由pending变为rejected。同时我们可以为Promise对象传入两个事件处理函数:then和catch,它们分别在Promise转为resolve和reject的时候被调用。
例如如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function somethingAsync(){
return new Promise((resolve, reject)=>{
console.log("开始执行一些复杂操作");
setTimeout(()=>{
console.log("1s之后复杂操作执行完了");
resolve();
}, 1000)
});
}
function main(){
somethingAsync().then(res=>{
console.log("第一个Promise执行完毕");
return somethingAsync();
}).then(res=>{
console.log("第二个Promise执行完毕");
return somethingAsync();
}).then(res=>{
console.log("第三个Promise执行完毕");
});
}
main();

这样看起来Promise好像并没有比普通的回调模式优雅太多,你看res=>console.log("Promise已resolved,执行完毕")这句,和写一个普通的callback有区别吗?
实际上,说Promise并没有什么作用是不科学的,因为它至少可以把回调套回调的模式变成then().then().then()的链式模式。但Promise的真正威力不在于这里,而在和async/await的结合上。

async/await是一组关键字,async用于声明在一个函数上,使其成为异步函数;而await相当于一个一元运算符,它只能在async函数中被使用。
任何函数一旦被声明为async类型,它的返回值就必定是Promise类型;即使你写的是普通的return 1之类的语句,当这个函数被调用时也是会很快的返回一个Promise对象,而非返回数值1的。
正是因为async函数的不会立刻返回结果的特点,为它内部等待其他异步操作的完成提供了可能。而await操作符作用在一个Promise上,它在逻辑上的作用就是让当前的async函数暂停,直到被await的那个Promise变为resolved后,继续函数的剩下部分(如果Promise被reject了,那么await操作符抛出一个异常)。于是上面的代码可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function somethingAsync(){
return new Promise((resolve, reject)=>{
console.log("开始执行一些复杂操作");
setTimeout(()=>{
console.log("1s之后复杂操作执行完了");
resolve();
}, 1000)
});
}
async function main(){
await somethingAsync();
console.log("第一个Promise执行完毕");
await somethingAsync();
console.log("第二个Promise执行完毕");
await somethingAsync();
console.log("第三个Promise执行完毕");
}
main();

async/await让回调函数彻底消失了,书写这样一个要反复在事件循环里异步回调的函数,就像书写普通函数一样简单,这才是优雅的异步编程。

总结

通过以上对异步编程模型的综述和对近年来比较先进得到广泛好评的JavaScript Promise-async/await模型的具体叙述,让我们了解了程序异步执行的发展历史、各种方式的优缺点和演进的必要性。
不可否认的是,大规模计算意义上对并行的追求和软件工程意义上对并行的追求并不完全重合。单线程只靠协程的模型虽然安全简洁,但是却大多数情况下只能利用一个有效核心,效率反而不太高。这也就是为什么许多基于C语言的并行计算框架时至今日还在被广泛使用的原因。
然而,在如今软件工程快速迭代开发的背景下,更简便、易学、易用、接近人的思维习惯的异步模型是更加适合于软件工程,特别是前端等对运行效率要求不太高但开发人力精力成本投入较大的场合所使用的,这也就是异步模型革新变化的源动力。