一. 同步和异步

要完全理解异步编程需要先理解几个概念

  1. 任务
    我给任务的定义是完成某项功能的单元模块,任务有大有小,站在操作系统的角度,一个程序就是一个任务,每当运行一个程序就会创建一个新的任务,它在操作系统中还有一个无人不知的名字:进程。站在编程的角度任务指我们编写的一系列函数,每个函数完成一个特定的功能(任务),一般我们首先构建出最基本的任务,然后组合这些基本的任务构建一些复杂的任务,程序的大厦就是这样一步步建立起来的。
  2. 同步任务和异步任务
    同步任务和异步任务描述的是任务执行本身的特征。同步任务指这个任务执行后一直要等到有了预期的结果才会返回,比如定义一个函数执行一个数学计算。异步任务指这个任务执行后在还没有得到预期的结果时就立马返回,所以异步任务一般会有一个或多个回调函数,在任务完成后会通过回调通知调用者。
  3. 同步执行和异步执行
    同步执行和异步执行描述的是任务的结果顺序和执行顺序是否一致。同步执行指在要执行的多个任务里,不管任务是否异步,后一个任务必须要等到前一个任务取得了预期的结果才能执行,即任务的结果顺序和执行顺序是一致的,是同步的。比如说具有依赖关系的多个异步任务,它们本身虽然是异步执行的,但是它们的整体执行顺序是同步的,所以它们还是同步执行的。异步执行指在包含异步任务的多个任务里,不需要异步任务有了预期的结果就立即执行其它任务,它们可以同时进行(并行),究竟是何种方式取决于系统资源(多核、单核)以及任务的调度方式,所以后执行的可能先有结果,即结果顺序和执行顺序不一致,是异步的。
二. 为什么要异步编程

同步通常更符合人的思维习惯,所以同步编程的代码通常结构清晰,容易理解,任务之间的调用顺序通常就代表了它们的执行顺序。但异步编程就完全不同了,任务与任务之间没有明确的时序关系,一个任务何时结束我们也不知道,只能在回调里面才能进行下一步动作,这样就打乱了我们思维的连贯性,所以必须小心翼翼,很容易出错。既然异步编程有这么大的缺点,为什么还要异步编程呢,我觉得理由有两点

  1. 有些场景只能异步
    比如UI线程,它不能被阻塞,必须快速响应各种事件,这样才能保持用户交互界面的流畅。还有就是JS执行环境,都知道它是单线程的,而且它的执行和UI线程有直接的关系,所以它也不能被阻塞。
  2. 异步具有更高的效率
    任务一般分为IO密集型和CPU密集型,不同类型的任务是可以同时执行的也就是真正意义上的并行,即使同种类型的任务也能并行,比如一个访问内存,一个读写硬盘,一个在CPU核1运行,一个在CPU核2运行,只要它们访问的资源没有冲突,之间没有依赖,那么我们就没必要等到一个任务执行完成才执行下一个任务。所以异步能充分利用系统资源,在相同的时间内可以做更多的事情,它拥有更高的效率。
三. 如何异步编程

1. 异步的实现方式

我把异步的实现方式主要分为四种:

  1. 多进程
    这也是最早的异步实现方式,每一个任务启动一个单独的进程,通过系统的任务调度实现并行,任务之间通过内核的进程间通信机制通信。比如shell编程中我们就可以通过这种方式实现异步。但它有两个缺点,第一个是比较重,首先启动一个进程就比较费时,其次进程间通信的代价也比较昂贵。第二个就是进程并不是创建的越多任务的执行效率就越高,因为进程是通过内核调度运行的,调度本身也是需要代价的,当进程越来越多时,进程调度也就会越来越频繁,每个进程运行的时间就会越来越短,这样调度所花费的代价占用的比重就会越来越大,当调度本身的代价大于创建新进程取得的收益时,此时再创建进程不仅不会提高效率,相反还会降低效率。当然有些调度器比较先进(比如Linux的cfs),随着任务增加,任务的调度间隔不会无限缩短,它会有一个最小阈值,达到这个阈值后就不会再缩小调度间隔,但即使这样,调度周期就会延长,所以在同样的时间内,每个进程运行的时间还是会越来越少。所以不管怎样,任务并不是越多越好。
  2. 多线程
    线程是操作系统最基本的调度单元,这种异步实现方式和多进程类似,但这里是每一个任务启动一个单独的线程,线程的创建相比于进程则要轻量很多,而且也不存在进程间通信的代价,所以相比于多进程它的代价要小很多。但这种方式也没法解决多进程的第二个缺点。
  3. 协程(coroutine)

协程(英语:coroutine)是计算机程序的一类组件,推广了协作式多任务的子例程,允许执行被挂起与被恢复。相对子例程而言,协程更为一般和灵活,但在实践中使用没有子例程那样广泛。协程更适合于用来实现彼此熟悉的程序组件,如协作式多任务、异常处理、事件循环、迭代器、无限列表和管道。

上面是维基百科上一段对协程的描述,用人话说就是协程是计算机程序的一类组件(子例程),或者更通俗的理解就是函数,但此函数非彼函数,这是一种比较特殊的函数,它在执行过程中可以被挂起与被恢复,当然这里的挂起和恢复可以是真正意义上的,也可以是逻辑上的,后面介绍协程的分类时会说。协程相比于上面两种方式,它要高效的多,所以它是一种更好的异步实现方式,主要体现在以下三点:

  • 它的创建比线程更加轻量,甚至不需要创建,对于一些原生支持异步编程的语言,在编译期就完成了创建。
  • 它同样没有进程间通信的代价,因为它们还是在同一个进程当中。
  • 协程不需要内核的调度,虽然有些协程实现了自己的调度器,但都只是用户层的一个行为,不会增加内核调度的负担,所以协程可以创建很多很多,它的数目可以远远多于进程和线程的数目。

正是由于协程的巨大优势,所以不管是各种语言还是各个厂家对异步的实现方式都更倾向于使用协程,后面我要讲的也都是协程的实现方式。

  1. 回调(callback)
    这也是异步的一种实现方式是不是很惊讶?但我觉得它确实也是一种方式,所以把它列在了这里,而且它是最基础的方式,因为操作系统就是通过这种方式给上层提供了异步执行的能力,所以它也是其它异步实现的基础。它一般在启动任务的同时也会提供任务完成的回调,所以任务启动后可以在没有结果前就立即返回,当任务完成时通过调用回调执行后续的操作。JavaScript早期就是通过这种方式实现异步操作,即使后来的Promise其实本质上还是回调的方式,只是改进了最原始的回调方式,把回调的组织方式从嵌套变成了链式。

2. 异步实现的提供方式

  1. 第三方库提供支持
    • libco
    • boost协程库
  2. 语言原生支持,一般在语法层面提供关键字
    • JavaScript的async、await关键字
    • C#的async、await关键字
    • Vala的async、yield关键字
    • go的go关键字
四. 协程的分类

协程按实现方式可以分为以下两大类:

  • 有栈(stackfull)协程
    有栈协程在挂起时会保存现场(寄存器、执行栈)到自己的私有数据区,在恢复时再载入这些数据,因而挂起点就是恢复点,所以这里的挂起和恢复是真正意义上的挂起和恢复,非常类似于操作系统中的线程,唯一的区别是操作系统的线程是由操作系统抢占调度的,不管是挂起还是恢复都取决于操作系统的任务调度系统。而协程的挂起是因为自己主动让出,然后由其它控制流恢复。
  • 无栈(stackless)协程
    无栈协程在挂起时一般不会保存运行现场(比如寄存器环境和执行栈),它的挂起其实不能算是真正意义上的挂起,只是逻辑上的挂起。它会把内部的指令拆成多个片段,会保存当前所处的状态,入口处有个跳转分发器,每次恢复执行会根据当前的内部状态跳转到不同的片段,所以无栈协程本质上是一个有限状态机(类似于计算机系统)。它的挂起就是某个片段执行完之后返回,它的恢复就是对子例程的重新调用。

优缺点比较:

  • 有栈协程在使用上拥有更少的限制,更大的灵活性,现有代码迁移更容易,相比于无栈协程更加强大。缺点是会消耗更多的资源(需要分配内存在挂起时保存环境),会有些额外的性能损耗(挂起和恢复都需要拷贝执行环境)。
  • 无栈协程相比于有栈协程,优点是更加轻量,实现更简单,性能更好。缺点是在使用上限制比较多,不如有栈协程灵活,现有代码迁移比较麻烦,一般只在语言原生支持时才会采用这种方式,因为编译器能保证用户使用的正确性,在超出限制时也能给出提醒。

有栈协程按调度方式又分为两类:

  • 对称的
    对称协程有自己独立的调度器,除了挂起是由自己主动让出,它更像一个操作系统的原生线程,协程的调度运行完全取决于自己的调度器,在一个协程挂起后可以调度任何一个协程。
  • 非对称的
    非对称协程没有单独的调度器,相比于对称协程多了些限制,一个协程被挂起后只能返回到调用者,也就是协程的调度是有严格限制的。
五. 各种原生支持异步编程语言漫谈

1. JavaScript

  • Promise
    JS的Promise本质上是对原始的回调方式进行了一层封装,在构造Promise对象时要求传入一个函数A,A函数带有两个参数resolve和reject,这两个参数都是可调用对象。A函数在Promise对象构造时就会被同步调用,它的主要作用就是启动一个异步任务,在异步任务的通知回调里调用resolve或reject,resolve和reject这两个函数都是由Promise提供的,主要作用是修改Promise的状态以及启动用户注册的回调。A函数执行完后立马返回,不会等待异步任务的完成。Promise还提供了一个函数then,这个函数也最多可以有两个参数,都是可调用对象,它就是用户注册的当异步任务完成或失败时的回调。Promise更有意思的是then的返回值,还是一个Promise,这意味着回调也被包装成了一个异步任务,后面可以继续调用then设置后续的任务。这样Promise相对于原始回调最大的好处就体现出来了,它把原始回调的组织方式从嵌套变成了链式,解决了回调地狱(Callback Hell)的问题,因而在异步编程上是一个很大的进步。
  • Generator
    这个不是JavaScript独有的东西,其它语言基本都有实现,主要作用就是延迟计算,一般用来实现迭代器,好处是不用把迭代的所有状态都一次性计算出来,而是每调用一次计算出一个,不调用就不用计算了。它本质上就是一个无栈协程,是一个有限状态机,根据内部状态和不同的输入执行不同的动作。
  • async和await关键字
    这两个关键字是JS在ECMAScript 2017中添加的,在语法层面对异步编程提供了支持,使编写异步任务更简洁更容易理解,形式上和同步任务除了多了两个关键字基本上没什么区别,大大减轻了程序员的负担。它既兼顾了异步的好处,又不失同步的简洁明了,所以很多人觉得它是异步编程的最终形态。下面是一段sample可以体验一下:
    async function myFetch() {
      let response = await fetch('coffee.jpg');
      let myBlob = await response.blob();

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

    myFetch()
    .catch(e => {
      console.log('There has been a problem with your fetch operation: ' + e.message);
    });

下面这段代码是上面那段代码的Promise版本

	fetch('coffee.jpg')
	.then(response => response.blob())
	.then(myBlob => {
	  let objectURL = URL.createObjectURL(myBlob);
	  let image = document.createElement('img');
	  image.src = objectURL;
	  document.body.appendChild(image);
	})
	.catch(e => {
	  console.log('There has been a problem with your fetch operation: ' + e.message);
	});

上面这两段代码完成了相同的功能,但async版本相比于Promise版本好处是去掉了then链,去掉了冗余逻辑,它比Promise版本更简洁,形式上非常类似于同步任务,功能一眼就能看明白。async把一个函数标记为异步函数,我就把它简称为async函数吧,async函数调用后会返回一个Promise,代表这是一个异步任务,await关键字的语义是保证这个异步任务完成后才能继续往下,形式上非常像同步任务的执行,但实际的执行过程它不是真的等在那,而是异步任务有了结果后才会从那里继续,所以它不会阻塞主线程的运行。

  • 实现原理
    虽然它看起来很强大,但它的实现其实并不复杂,async函数内部逻辑会被包装在一个generator中,await关键字把generator函数内部分成了多个片段,每次next调用都会根据内部状态跳转到不同的片段,由于它被转换成了一个Generator,所以还需要一个启动器,调用Gererator获取迭代器对象,并且调用next启动第一次的迭代,之后在每一个异步任务完成后会调用next自动跳转到下一个状态,直到结束状态,所以async函数就是一个自带启动器的 generator 函数。

2. C#

  • async、await关键字
    功能和实现和JavaScript的async、await差不多,编译器最终把async标记的函数转换成一个有限状态机,await划分出不同的状态。

3. Go

  • 关键字go
    Go(又称Golang)是所有这些语言中最特别的,而且在它的语言特性中最重要的就是并发,可以说就是为了并发而生的。为了立住这个flag,它在异步编程方面必须使用足够简单、功能足够强大、性能足够卓越。对称有栈协程刚好能满足它的所有想象,而实际的情况也确实如此,go关键字背后的实现确实是对称有栈协程。它有自己的协程调度器,而且还很复杂,可以实现M:N的调度,也就是能把M个协程调度运行在N个系统线程之上,充分利用了系统资源,所以它鼓吹的并发也是实至名归的。
六. 总结