Coroutine的概念说难不难说简单也不简单。昨天查阅了一些东西大致上感觉搞懂了,没多少实践光是看了下Wiki就敢写文章讲一个概念,这种蜜汁自信到底是从哪里来的?不可能没有错,希望能指出。

(线程和进程都可能会专门写笔记记录,这里为了流畅,大致说一下。)

先来看进程。进程一开始作为操作系统调度任务和管理资源、权限等的基本单位。因为进程要干的事情那么多,所以操作系统会为每个进程创建很多数据结构,也就是进程的context。当系统决定切换进程的时候这些context都需要切换,开销很大。

于是就有了线程,线程将资源管理和调度分离,在同一个进程的不同线程间切换的时候就只需要切换调度相关的 context 就行了,开销更小了。而且因为资源管理是在一起的,所以线程间能很方便的共享资源。

但对线程的执行是系统调度的,系统有一套精密的抢占式多任务调度算法,然而调度行为很难被程序所控制,所以不能对不同间线程的执行顺序有过多的假定,这引起了很多问题。

与之对应的有用户级线程的概念,在一个单独的进程里用自身的调度逻辑来调度「线程」。当然不是说使用用户级线程就是为了解决线程的问题,只是说和操作系统调度相对应,是自己调度。

用户级线程实际上很难说是一般意义的线程,实际上是在单线程的逻辑中模拟控制流调度的产物,如果不用别的方式配合的话是没有办法光靠用户级线程来真正创建多个系统线程运行在不同核心的。(之后打算看看goroutine怎么实现的。)

但是我们很多时候需要线程不是因为想要达到并行,仅仅是为了并发而已,一条控制流也够了,只要不阻塞。

协程就是用户级线程的一种实现方式。

和现代大多数操作系统采用的抢占式多任务不同,协程靠的是协作式多任务。意思是控制流不会被中断,除非自己主动让渡自身控制权给调度器。让渡的时候也会保存上下文。

协作式多任务用在操作系统上是落后的,但是重生在单一线程上却很有用,因为程序员能整体把控自己程序的控制流,而且协作式多任务的控制流切换也往往更可控。

好,现在甩甩头,有人说协程是函数(子例程,subroutine)的推广,那么我们先从函数看起。

函数在数据上就是串指令序列而已,函数的数据编译时就确定,被加载以后,在调用前后都不会在内存中驻留任何东西,执行一个函数只需要知道它的地址就行了。而在执行时函数就短暂地申请栈空间,函数的 context 可以看作是栈中的数据,寄存器尤其是 pc 寄存器的值所组成的。

那我们看函数的推广:closure函数(很多语言所有函数都是closure),和函数不同,closure在执行前后都不是安静而纯粹的指令,被创建出来以后就能看成一个运行时对象了。

Closure的代码一般和函数一样是编译时就确定的,但是创建时还需要捕获closure内对外部对象的引用。C++和 Java的玩家估计能猜到,这两种语言实现 closure的方法就是编译成小类,然后运行时动态捕获。这种捕获的过程似乎叫upvalue,所以closure就能看做普通函数+外部对象结构体对象,同一段代码会生成很多不同的closure对象,它们都不是一个对象,尽管函数指针都指向同一个函数。(不过它们有相同的类型。)

所以closure的运行时除了函数那些,还有默认传入的一个结构体对象,可以访问到创建时捕获的外部对象的引用。但closure的执行规则,比如说栈规则,比如说局部变量规则都是和普通函数无二的,只是作为一个对象能访问到特殊的捕获结构体而已。

现在到了我们的主角coroutine了,和closure相似coroutine也是一个在普通函数之上的运行时对象。但是它们两者的区别是,closure作为对象,除了函数指针,只储存函数所需的外部对象的引用。而coroutine,并不需要储存外部的引用,更关心的是函数执行过程中内部的状态。

也就是说,和closure相似,作为一个对象储存自身所需的信息,但是coroutine储存的是内部局部变量的执行状态,以及自身上次执行到的位置。普通函数这些context都是短暂居留的,当返回的时候pc会跳转,栈内的局部变量都会被丢弃,而coroutine却保存这些状态,coroutine退出以后因为context始终保存的原因,实际上是一种挂起的状态,而再次被调用的时候就是从保存的context中恢复执行状态。这和线程上下文切换是类似的,所以可以看作用户级线程。

一方面像进程,但另一方面又可以从函数的角度看,coroutine每次退出的时候都可以返回一个值,就像普通函数一样。实际上开头也说了,从功能上来说能把一个coroutine当作函数的加强版。

一般语言,coroutine就是通过 yield 来产出值的,从线程的角度上来看,使用 yield 以后的 coroutine 并没有消失,而是挂起,交出控制权,并等待下次被调度。从函数过程角度上来说,yield 以后coroutine自身就已经保存context、终止而返回了。

再仔细看看 yield 后发生什么,如果是一个普通过程调用的coroutine,那么就控制权回到调用者之处,并且调用者得到所yield的返回值。而如果 coroutine 所 yield 的是另一个coroutine的话,coroutine也会立即保存context并退出,然后重启所 yield 的coroutine,也就是说把控制权转出。

这里很重要的就是coroutine之间的 yield 不能用 return 来理解,而是一种调度,它们间是平等的,也就是说,不存在被调用者要返回到调用者调用处这样的关系,要回到调用者,必须显式 yield 原调用者,以一种类似相互递归的方式回到刚才的位置。

维基上的例子:

var q := new queue
coroutine produce
    loop
        while q is not full
            create some new items
            add the items to q
        yield to consume

coroutine consume
    loop
        while q is not empty
            remove some items from q
            use the items
        yield to produce

而如果不去调度另一个coroutine,直接 yield 一个普通值的话,最后的结果就是值会被返回到所有coroutine调用链条之前的那个通常调用处。

也就是说如果你在普通函数F里普通地调用A,A yield B,B yield C,C yield 42,那么42会直接返回到F。其中ABC都是coroutine。

实际上不要求F是普通函数,就算F也是coroutine也没关系,只和调用方式有关:yield调用时coroutine会立刻退出,栈也会直接释放。

而这种调用规则比较适合利用对代码CPS变换来实现……「我应该返回到哪儿」通过yield间传递continuation能优雅地实现。

至于在异步上具体的使用模式,还不是很清楚,感觉可以配合一些外部的异步原语和事件循环来调度。可以参考各种语言相关的库。

基本参考于Wikipedia