如何在C语言中添加Coroutine功能

330 阅读9分钟

先决条件

如果你是一个绝对的初学者,你可以通过以下所有的先决条件。如果你不是初学者,你最好知道该跳过什么

Coroutine基础知识

什么是Coroutine?

  • Coroutine是一个可以暂停和恢复的函数/子程序(准确地说,是合作子程序)。
  • 换句话说,你可以把coroutine看作是普通函数和线程之间的解决方案。因为,一旦函数/子程序被调用,它就一直执行到结束。另一方面,线程可以被同步原语(如mutex、semaphores等)阻断,或者被操作系统的调度器暂停。但同样地,你不能决定暂停和恢复(因为它是由操作系统调度器完成的)。
  • 有了coroutine,它可以在一个预定义的点上暂停,然后由程序员根据需要恢复。所以在这里,程序员将完全控制执行流程。与线程相比,它的开销也是最小的。
  • Coroutine也被称为本地线程、纤维(在windows中)、轻量级线程、绿色线程(在java中)等。

为什么我们需要coroutine?

正如我通常所做的那样,在学习任何新东西之前,你应该向自己提出这个问题。但是,让我来回答它。

  • Coroutines可以提供非常高的并发性,而且开销很小,因为它不需要操作系统干预调度。而在一个线程环境中,你必须承担操作系统的调度开销。
  • Coroutine可以在预先确定的点上暂停,所以你也可以避免在共享数据结构上锁定。因为你永远不会告诉你的代码在一个关键部分的中间切换到另一个coroutine。
  • 有了线程,每个线程都需要自己的堆栈,包括线程本地存储和其他东西。所以你的内存使用量会随着你的线程数量的增加而线性增长。而对于联合程序,你拥有的程序数量与你的内存使用没有直接关系。
  • 对于大多数用例来说,协同程序是一个更理想的选择,因为与线程相比,它更快。
  • 如果你还不相信,那么请等待我的C++ Coroutine帖子。

上下文切换的API理论

  • 在我们深入研究Coroutine之前,我们需要了解以下用于上下文切换的基础函数/APIs。偏偏在这个时候,我们要做的是,用更少的、到点的理论,用更多的代码例子。

    1. setcontext
    2. getcontext
    3. makecontext
    4. swapcontext
  • 如果你已经熟悉了setjmp/longjmp ,那么你可能会很容易理解这些函数。你可以把这些函数看作是setjmp/longjmp 的高级版本。

  • 唯一的区别是setjmp/longjmp只允许在堆栈中进行一次非本地跳转。而这些API允许创建多个合作的控制线程,每个线程都有自己的栈或入口点。

数据结构e来存储执行环境

  • 下面定义的ucontext_t 类型结构用于存储执行上下文。

  • 所有四个 (setcontext,getcontext,makecontext &swapcontext) 控制流函数都在这个结构上操作。

  • uc_link 指向当当前上下文退出时将被恢复的上下文,如果该上下文是用makecontext (一个辅助上下文)创建的。

  • uc_stack 是该上下文使用的堆栈。

  • uc_mcontext 存储执行状态,包括所有的寄存器和CPU标志,帧/基数指针(即表示当前的执行帧),指令指针(即程序计数器),链接寄存器(即存储返回地址)和堆栈指针(即表示当前的堆栈限制或当前帧的结束)。mcontext_t 是一个不透明的类型

  • uc_sigmask 是用来存储在上下文中被封锁的信号集。这并不是今天的重点。

int setcontext(const ucontext_t *ucp)

  • 这个函数将控制权转移到ucp 中的上下文。执行从上下文被存储在ucpsetcontext ,不返回。

int getcontext(ucontext_t *ucp)

  • 将当前上下文保存到ucp 。这个函数在两种可能的情况下返回。
    1. 在最初的调用之后。
    2. 或者当线程通过setcontextswapcontext 切换到ucp 的上下文时。
  • getcontext 函数不提供返回值来区分这些情况(它的返回值仅用于发出错误信号),所以程序员必须使用一个显式的标志变量,该变量不能是寄存器变量,并且必须声明为volatile ,以避免常数传播或其他编译器优化。

void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...)

  • makecontext 函数在ucp 中设置了一个备用的控制线程,该线程之前已经用getcontext 进行了初始化。
  • ucp.uc_stack 成员应该指向一个适当大小的堆栈;通常使用常数 SIGSTKSZ 或 MINSIGSTKSZ。
  • 当使用setcontextswapcontext 跳转到ucp 时,执行将从func 指向的函数的入口点开始,参数为argc 指定。当func 终止时,控制权将返回到ucp.uc_link 中指定的上下文。

int swapcontext(ucontext_t *oucp, ucontext_t *ucp)

  • 将当前的执行状态保存到oucp ,然后将执行控制转移到ucp

[例1]了解使用setcontextgetcontext 函数进行上下文切换的情况

  • 现在,我们已经读了很多理论。让我们来创造它的意义。

  • 考虑一下下面这个程序,它实现了普通的无限循环,每秒钟打印 "Hello world"。

  • 这里,getcontext在两种可能的情况下返回,正如我们前面提到的,即。

    1. 在最初的调用之后。
    2. 当一个线程通过setcontext 切换到上下文时。
  • 剩下的就不言自明了。

[例2]通过makecontextswapcontext 函数理解控制流

  • 这里,makecontext 函数在ctx 中设置了一个备用的控制线程。当使用swapcontextctx 中进行跳转时,执行将从assign 开始,并指定各自的参数。

  • assign 终止时,控制将被切换到ctx.uc_link 。在跳转/上下文切换之前,它指向back ,并由swapcontext 填充。

  • 如果ctx.uc_link ,那么当前的执行上下文被认为是主上下文,当assign 上下文结束时,线程将退出。

  • 在调用makecontext 之前,应用程序/开发人员需要确保被修改的上下文有一个预分配的堆栈。而且argc 与传递给funcint 类型的参数数量相匹配。否则,该行为将无法定义。

  • 最初,我创建了单文件的例子。但后来我意识到,对于一个文件来说,这太多了。因此,我把实现和使用的例子分成了不同的文件,这将使这个例子更容易理解和掌握。## Coroutine的实现

  • 所以,这里是c语言中最简单的cououtine。

coroutine.h

  • 到现在为止,只需忽略coroutine的API。
  • 这里要关注的主要是有以下字段的coroutine处理程序。
    • function: 它保存了用户提供的实际程序函数的地址。
    • suspend_context: 用于暂停该轮回函数。
    • resume_context: 用于保存实际轮转函数的上下文。
    • yield_value: 用于存储中间暂停点和最终返回值之间的返回值。
    • is_coro_finished: 一个指示器,用于检查coroutine生命周期的状态。

coroutine.c

  • Coroutine最常用的API是coro_resumecoro_yield ,它拖动了暂停和恢复的实际工作。
  • 如果你已经有意识地浏览了上面的上下文切换API例子,那么我认为对于coro_resumecoro_yield 没有什么好解释的。它只是coro_yield 跳转到coro_resume ,反之亦然。除了第一次调用coro_resume ,跳转到_coro_entry_point
  • coro_new 函数为处理程序和堆栈分配内存,然后填充处理程序成员。同样,getcontextmakecontext 在这一点上应该是清楚的。如果没有,那么请重新阅读上面的上下文切换API示例部分。
  • 如果你真正理解了上述的coroutine API实现,那么显而易见的问题是我们为什么需要_coro_entry_point ?为什么我们不能直接跳到实际的coroutine函数?
    • 但是,我的论点是 "你如何确保coroutine的寿命?"
    • 这在技术上意味着,对coro_resume 的调用次数应该与对coro_yield 的调用次数相近/有效,再加上一个(用于实际返回)。
    • 否则,你就无法跟踪收益率。而且行为会变得不确定。
  • 尽管如此,_coro_entry_point 函数是需要的,否则你就无法推断出循环程序的执行是否完全结束。而且下一个/后续调用coro_resume ,也不再有效。

Coroutine的寿命

  • 通过上面的实现,使用一个coroutine处理程序,你应该在整个程序/应用寿命中只能完全执行一次coroutine函数。
  • 如果你想再次调用该轮回函数,那么你需要创建一个新的轮回处理器。而其余的过程将保持不变。

Coroutine使用实例

coroutine_example.c

  • 用例是非常直接的。
    • 首先,你创建一个轮回处理程序。
    • 然后,你在同一个处理程序的帮助下启动/恢复实际的轮回函数。
    • 而且,每当你的实际轮回函数遇到调用coro_yield ,它将暂停执行并返回coro_yield 的第二个参数中传递的值。
  • 而当实际的轮回函数执行完全结束时。对coro_resume 的调用将返回-1 ,以表明该轮回处理程序对象不再有效,并且其生命周期已过。
  • 因此,你可以看到coro_resume 是我们的冠状程序hello_world 的一个包装器,它分部分执行hello_world (显然是通过上下文切换)。

编译

  • 我在WSL中使用GCC 9.3.0和Glibc 2.31测试了这个例子:
$ gcc -I./ coroutine_example.c coroutine.c  -o myapp && ./myapp 

临别赠言

你看,如果你了解CPU是如何执行代码的,就不会有什么魔力,因为Glibc提供了一套丰富的上下文切换API。而且,从底层开发者的角度来看,它只是一个排列整齐且难以组织/维护的(如果原始使用)上下文切换函数调用。

我在这里的目的是为C++20的Coroutine打下基础。因为我相信,如果你从CPU和编译器的角度来看待代码,那么在C++中一切都会变得容易推理。

下次见我的C++20 Coroutine帖子!