# 协程与异步模型:以 JavaScript 为例
# 前言与基础概念
在现代计算机软件结构里,异步永远是绕不开的话题。无论是进行一些耗时操作,还是执行网络请求这种需要暂停一段时间才能继续的任务,都不可能允许单个线程阻塞的执行。否则,在该线程执行的期间,UI 界面等其余所有的任务都会卡死。
因此,如何让程序员优雅的完成异步编程,就变成了架构师们的难题。
在此之前有必要简述几个概念:
- 进程:操作系统控制应用程序的单位。任何应用程序都有且只有一个进程,操作系统会为每个进程分配一些独占的内存空间。
- 线程:经由操作系统,交付给 CPU 执行指令序列的单位。一个进程可以创建多个线程,它们共享同一块内存空间,交替在 CPU 上执行任务;具体每个线程执行的时间则由操作系统控制。
- 协程:一些编程语言或库中的概念,是物理串行(只会占据一个 CPU 处理)、逻辑并行(在程序员看起来是并发)的。内部指令执行的先后、顺序是由进程自己控制,与操作系统无关。
最早的 VC 是通过 Windows API 来调用系统库完成线程的构建的,需要操作句柄等,比较麻烦。C++ 和 Java 等经典的面向对象编程语言则是使用了线程对象 + 多线程阻塞操作的异步模型。
举例而言,在 Java 中,我们可以:
Thread th = new Thread(){ | |
@Override | |
public void run(){ | |
// 做一些事情,例如执行网络请求 | |
// 这些事情可以阻塞线程直到事情结束,如网络请求,函数在收到响应体后才会返回 | |
} | |
}; | |
th.start(); |
为此,在大型的 Java 框架如 Tomcat 中都有线程池的概念,由框架维护一系列的线程并分配给具体的事务(如处理一个网络请求)。
这样做的缺点包括:
- 线程是基于系统的线程调度的,因此比较 “重量”,线程开启、关闭等需要很多的额外开销,且线程的执行状态程序不直接可控。
- 线程共享同一块内存区域,因此在并发操作的情况下存在访问冲突的问题,需要加锁。
有过 Java 编程经验的人对锁肯定是又爱又恨,比如死锁之类的问题,这里就不再赘述了。
而 JavaScript 语言(这里及以下所有提到 js 的场合均指 Node.js)则提供了一种全新的异步模型:单线程协程模型。
# Node.js 事件循环
整个 js 是始终单线程工作的(除了个别对系统 api 的调用),所有的函数都在一个线程下被依次的执行。但是,很显然实际当中有太多不能够立即完成的操作,这该怎么办呢?
为此,js 内部提供了一个事件循环。形象地讲,js 的代码始终是跑在一个 while (true) 的循环里的,而你写的所有代码则是从这个循环里被调用的。在每一轮循环里,node 会遍历一个事件队列:它由一系列事件和注册在事件上的回调函数构成。
(注:本文所描述的是 Node.js 事件循环的一个高度简化的模型。实际上事件循环对不同种类的事件有复杂的调度策略,但在这里我们并不讨论;又由于回调函数和事件之间存在绑定关系,我们不区分事件和它的处理函数的区别,就认为事件循环是在一个一个的直接调用先前注册在其中的一系列函数即可。)
可以用下面的伪代码表示:
while(true){
for(handler in eventQueue){
handler();
}
}
而许多耗时操作,如网络请求、读写文件等,提供的 API 不再是同步阻塞线程式的,而是异步式的。
例如发送 GET 网络请求的 API 格式为: http.get(url[, options][, callback])
。以下是 Node.js 官方文档给出的示例:
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 异步非阻塞模型的优点:
- 对操作系统而言始终是一个线程在切换不同的指令组而已,没有新建线程创建栈、预置缓存等开销,异步操作更 “轻量”;
- 始终是单线程操作,不存在多个处理器同时访问数据或某个函数执行到一半控制权被转移的情况,也就自然不需要锁了;
- 程序员对异步操作的控制力更强。只要是连续写在一起的代码,能够保证被连续的执行,不会因中断造成错误;而希望转移控制权的地方可以主动交出控制权。
# 更优雅的异步编程:Promise 与 async/await
但是程序员们仍然认为,上面依靠回调函数的异步还不够优雅和简洁。为此,经过了广泛的讨论和大量的时间,js 终于在其标准中加入了利器: Promise
和 async/await
。Promise
是一种对异步回调模式的有效封装,一个 Promise
对象有三种状态:pending、resolved 和 rejected,和一个唯一的事件处理函数 handler
, handler
有两个函数类型的参数 resolve
和 reject
。
当 resolve
被调用时, Promise
的状态就会由 pending 变为 resolved,反之当 reject 被调用时, Promise
的状态就会由 pending 变为 rejected。同时我们可以为 Promise
对象传入两个事件处理函数:then 和 catch,它们分别在 Promise 转为 resolve 和 reject 的时候被调用。
例如如下代码:
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 操作符抛出一个异常)。于是上面的代码可以这样写:
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 语言的并行计算框架时至今日还在被广泛使用的原因。
然而,在如今软件工程快速迭代开发的背景下,更简便、易学、易用、接近人的思维习惯的异步模型是更加适合于软件工程,特别是前端等对运行效率要求不太高但开发人力精力成本投入较大的场合所使用的,这也就是异步模型革新变化的源动力。