C++20中的Coroutine(一)

Sun 18 August 2019 / In categories Programming

C++20, Coroutine

Coroutine应该会顺利进入C++20标准中了,本文稍微学习和探讨一下C++的Coroutine。

序言

Coroutine并不是什么新鲜概念,而是在打孔计算机时代就存在的概念。简单来说Coroutine和一个函数十分类似,都可以被调用,并返回一定的结果。区别在于,普通函数只能一次调用一次返回,而Coroutine则可以多次调用,并且多次返回,每次可以返回不同的值。由于这个特性,Coroutine自身可以在多次返回之间保存一定的状态,有时候可以简化代码组织。

通常意义上,代码的复杂度来源于代码状态中的复杂度。如果以数据驱动的方式(也就是用表)来管理状态的话,由于状态和驱动状态的事件之间的映射会有冗余和重复的地方,导致代码膨胀繁杂。举一个简单的例子,如果一个对象有三种状态:开始、工作中、暂停。对于一个外部事件,该对象必须定义在每一种状态下如何处理此事件。推而广之,每新增一个事件,就必须在每个状态定义对事件的处理。可能出现的情况是某些事件在某种状态之上可能压根儿没有意义,但既然是表驱动,就得完备,所有的可能性都得考虑。

如果使用Coroutine来管理状态就会简化一些。每个Coroutine代表某种意图,要完成某种任务。基于Coroutine来管理状态,就是基于意图来管理状态,这样容易忽略那些跟意图无关的事件,不必面面俱到。同时Coroutine是动态的,又具有生命周期,在其生命周期结束的时候,也可以回避相应的事件处理。跟表驱动的静态的状态管理不同,Coroutine是动态的,可调度的,可规划的,利用到了运行时的信息。

并发和异步

Coroutine常常用于并发和异步。并发是指打包代码,在合适的时候放置到操作系统的执行单元(进程,线程)上执行。异步指的是不要在调用一个子过程的时候将自己阻塞(被操作系统接管)。

现代的操作系统对其执行单元(进程、线程)的管理都是非常粗放式的。一旦一个可执行体(一个程序或者一段代码)被加载执行之后,这个可执行体就得不断执行,直到执行结束,或者因为某些操作阻塞在操作系统内核之中。说些不好听的,这些可执行体就像没有理性的疯狗,不知道自己为什么生,为什么死,为何被释放出来,也不知自己为何被限制行动,总之唯一会做的就是在有行动权力的时候盯着目标乱咬。这是因为操作系统对执行单元的调度是基于贪婪的假设。也就是说,操作系统认为,一个可执行体的目的一定是尽最大努力来消耗计算资源(CPU资源),直至被制止,或者因为需要等待操作系统提供某种资源而阻塞。

而所谓的并发和异步编程其实就是为了从操作系统拿回一些颜面。前面说了,并发是为了自己打包可执行体,从而自己可以管理这些可执行体。而异步则是避免立即执行那些可执行体,而是等到自己觉得合适的时候再做执行。

栈自带(Stackful)和栈借用(Stackless)

通常Coroutine的实现可以分为两种:栈自带和栈借用。如果是栈自带,每个Coroutine都会动态分配一段栈(就像每个线程有自己的栈一样);如果是栈借用,则是需要借用Coroutine调用者的栈。

这两种实现各有好坏,简单总结地说,栈自带的Coroutine因为不受宿主栈的限制,其挂起和恢复更加灵活,在一个Coroutine内部嵌套调用了多个子函数之后,依然可以挂起返回宿主的上下文,但是缺点也很明显,那就是每个Coroutine都需要动态分配栈,更消耗内存。

栈借用的Coroutine跟普通的函数调用类似,但是由于没有自带的栈,所以挂起的时候只能将当前Coroutine的状态保存到动态分配的内存,而无法将整个调用链保存起来。内存消耗是减少了,可是某个Coroutine只能等所有嵌套调用的子函数返回之后,才能被挂起。另外,由于寄生在宿主栈上,挂起的时候需要将当前栈帧上的内容保存到动态内存,恢复的时候需要从动态内存中恢复出堆栈,这两个操作都需要编译器在语言层面提供支持。

和栈借用类型的Coroutine相比,栈自带类型的Coroutine不需要语言层面的支持,完全可以用库实现。如COROUTINES INTRODUCTION一文中描述的,栈自带类型的Coroutine有:

另外像Green Threads,Fiber,Goroutine等等都是栈自带类型的Coroutine。

从实现上讲,栈自带的Coroutine可以借用操作系统提供的API来实现。比如上面列举的libtask,是通过setcontext接口来实现不同Coroutine之间的栈切换。另外,通常而言,栈自带的Coroutine需要实现一个调度器,来管理所有Coroutine,提供类似操作系统提供的服务。一个还是来自于libtask的例子,其所有Coroutine的IO都是非阻塞的,IO是否就绪由一个专门的task来管理,这个task通过poll接口轮询文件描述符集合,然后在文件描述符就绪的时候唤醒等待IO的task,让调度器重新调度这个task。所以libtask中,只有负责poll文件描述符的task可以阻塞,其他task都不能阻塞。因此,负责poll的task必须设置为优先级最低,等其他task都挂起后才能执行。

另外值得一提的是,libtask的作者也是Go语言的主力开发者。不怀疑Go语言的goroutine也是类似实现。

参考:

(本篇完)

Load Disqus Comments