异步编程
异步是相对于同步而言的。同的执行模式是:执行——返回结果。异步的执行模式是:执行——执行其他任务——稍后返回/查询结果。
为什么要异步?
使用异步编程的理由和使用线程的理由差不多。主要是为了提升性能,提高硬件的使用率。除此之外,就是程序天然是并发的,使用异步更为模块化(比如文本编辑器中自动保存文本的功能适宜用线程实现)。这种情况一般都是使用一个在程序运行期间始终存在的线程,情况比较简单,一般直接使用线程。
在软件层面使用异步编程,更多的体现了底层硬件是并行运行的。比如网络或磁盘IO,进行IO操作时并不使用CPU,而传统的串行编程模式把整个计算机看成一个整体,进行IO操作时CPU也停下来等待操作完成。对程序员而言可能更好理解,但性能更差。现代计算机有多个CPU,异步编程也可以更好的利用多个CPU。
异步编程模式
thread
最传统的异步编程模式就是使用线程。线程提供了fork/join式的并发原语,再配合互斥锁和条件变量,以及用互斥锁和条件变量构建的其他同步器(或使用非阻塞算法实现的数据结构),程序员可以构建任意的并发程序。
但使用线程进行异步编程有以下缺点。
并发难以处理。虽然使用互斥锁和条件产量可以构建任意的同步器,但正确的构建同步器是一项复杂的任务。
难以组合复用。线程运行的函数返回值为void,难以组合。很难把构建好的线程组合成新的线程。
系统级线程开销大。创建系统级线程需要消耗内存,调度线程开销大。一个阻塞操作就会消耗一个线程,使异步操作变得昂贵。使用用户级线程(或线程池,不过线程池中还是系统级线程,使用阻塞操作还是会消耗一个线程。用户级线程一般都有自己的同步原语和调度机制)和基于epoll的IO操作可解决此问题。
callback
把后续操作(continuation)放入回调函数中是最简单的异步编程模式。由接受回调函数的函数负责异步操作,把异步操作封装起来。回调函数中可以再有回调函数,从而把程序组合起来。但这种组合方式很难理解。
event bus
node.js和其他一些单线程系统(比如python)使用的异步模式。通过中央的事件总线分发异步任务,可以看成是一个中央的负责分发任务的actor,也可以看成是一个全局的可发布事件的obersevable。
如果中央事件总线是单线程的还可以起到同步的作用。
但这种模式一般都会使用回调函数监听事件,存在和使用回调函数相同的弊端。此外,如果中央事件总线是单线程的,那么中央事件总线可能成为全局的竞争点,变成性能瓶颈。
actor
actor的核心基本上就是一个用户级线程绑定了一个消息队列(mailbox),并且只能通过该消息队列通信。基于csp的goroutine和此类似,只是一个用户级线程可以有多个消息队列(channel),并且还可以共享内存。
actor模式的核心,也是把并发简化的关键就是只通过mailbox通信,一个actor内部的代码无需同步就是串行执行的。我们可以做如下抽象,
run: TaskQueue -> (Unit -> Unit)
在同一个TaskQueue上执行的任务会被串行化执行。
reactive stream的实现也会用到类似的抽象。
actor只能发送接受消息,无法直接返回值,也是基于延续(continuation)的,因而难以组合。
future
future适用于只计算一次,返回一个值(或零个值)的情况下使用。future是一个单子,提供了一系列的组合函数。这种函数式的接口使得future的使用和串行编程类似。如果语言对单子提供了特殊的语法糖,那在语法上也可以和串行编程类似。有的语言提供了生成器,使用生成器可以为单子提供语法糖,这说明了单子是可编程的逗号。
reactive stream
响应式流可看做是future的扩展,它的计算不止产生一个值,而是多个值组成的一个流。reactive stream也是一个单子。
总结
上面的异步模式中,我认为最简单好用的是future和reactive stream。