先决条件
如果你是一个绝对的初学者,你可以通过以下所有的先决条件。如果你不是初学者,你最好知道该跳过什么
Coroutine基础知识
什么是Coroutine?
- Coroutine是一个可以暂停和恢复的函数/子程序(准确地说,是合作子程序)。
- 换句话说,你可以把coroutine看作是普通函数和线程之间的解决方案。因为,一旦函数/子程序被调用,它就一直执行到结束。另一方面,线程可以被同步原语(如mutex、semaphores等)阻断,或者被操作系统的调度器暂停。但同样地,你不能决定暂停和恢复(因为它是由操作系统调度器完成的)。
- 有了coroutine,它可以在一个预定义的点上暂停,然后由程序员根据需要恢复。所以在这里,程序员将完全控制执行流程。与线程相比,它的开销也是最小的。
- Coroutine也被称为本地线程、纤维(在windows中)、轻量级线程、绿色线程(在java中)等。
为什么我们需要coroutine?
正如我通常所做的那样,在学习任何新东西之前,你应该向自己提出这个问题。但是,让我来回答它。
- Coroutines可以提供非常高的并发性,而且开销很小,因为它不需要操作系统干预调度。而在一个线程环境中,你必须承担操作系统的调度开销。
- Coroutine可以在预先确定的点上暂停,所以你也可以避免在共享数据结构上锁定。因为你永远不会告诉你的代码在一个关键部分的中间切换到另一个coroutine。
- 有了线程,每个线程都需要自己的堆栈,包括线程本地存储和其他东西。所以你的内存使用量会随着你的线程数量的增加而线性增长。而对于联合程序,你拥有的程序数量与你的内存使用没有直接关系。
- 对于大多数用例来说,协同程序是一个更理想的选择,因为与线程相比,它更快。
- 如果你还不相信,那么请等待我的C++ Coroutine帖子。
上下文切换的API理论
-
在我们深入研究Coroutine之前,我们需要了解以下用于上下文切换的基础函数/APIs。偏偏在这个时候,我们要做的是,用更少的、到点的理论,用更多的代码例子。
setcontextgetcontextmakecontextswapcontext
-
如果你已经熟悉了
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中的上下文。执行从上下文被存储在ucp。setcontext,不返回。
int getcontext(ucontext_t *ucp)
- 将当前上下文保存到
ucp。这个函数在两种可能的情况下返回。- 在最初的调用之后。
- 或者当线程通过
setcontext或swapcontext切换到ucp的上下文时。
getcontext函数不提供返回值来区分这些情况(它的返回值仅用于发出错误信号),所以程序员必须使用一个显式的标志变量,该变量不能是寄存器变量,并且必须声明为volatile,以避免常数传播或其他编译器优化。
void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...)
makecontext函数在ucp中设置了一个备用的控制线程,该线程之前已经用getcontext进行了初始化。ucp.uc_stack成员应该指向一个适当大小的堆栈;通常使用常数 SIGSTKSZ 或 MINSIGSTKSZ。- 当使用
setcontext或swapcontext跳转到ucp时,执行将从func指向的函数的入口点开始,参数为argc指定。当func终止时,控制权将返回到ucp.uc_link中指定的上下文。
int swapcontext(ucontext_t *oucp, ucontext_t *ucp)
- 将当前的执行状态保存到
oucp,然后将执行控制转移到ucp。
[例1]了解使用setcontext 和getcontext 函数进行上下文切换的情况
-
现在,我们已经读了很多理论。让我们来创造它的意义。
-
考虑一下下面这个程序,它实现了普通的无限循环,每秒钟打印 "Hello world"。
-
这里,
getcontext在两种可能的情况下返回,正如我们前面提到的,即。- 在最初的调用之后。
- 当一个线程通过
setcontext切换到上下文时。
-
剩下的就不言自明了。
[例2]通过makecontext 和swapcontext 函数理解控制流
-
这里,
makecontext函数在ctx中设置了一个备用的控制线程。当使用swapcontext在ctx中进行跳转时,执行将从assign开始,并指定各自的参数。 -
当
assign终止时,控制将被切换到ctx.uc_link。在跳转/上下文切换之前,它指向back,并由swapcontext填充。 -
如果
ctx.uc_link,那么当前的执行上下文被认为是主上下文,当assign上下文结束时,线程将退出。 -
在调用
makecontext之前,应用程序/开发人员需要确保被修改的上下文有一个预分配的堆栈。而且argc与传递给func的int类型的参数数量相匹配。否则,该行为将无法定义。 -
最初,我创建了单文件的例子。但后来我意识到,对于一个文件来说,这太多了。因此,我把实现和使用的例子分成了不同的文件,这将使这个例子更容易理解和掌握。## Coroutine的实现
-
所以,这里是c语言中最简单的cououtine。
coroutine.h
- 到现在为止,只需忽略coroutine的API。
- 这里要关注的主要是有以下字段的coroutine处理程序。
function: 它保存了用户提供的实际程序函数的地址。suspend_context: 用于暂停该轮回函数。resume_context: 用于保存实际轮转函数的上下文。yield_value: 用于存储中间暂停点和最终返回值之间的返回值。is_coro_finished: 一个指示器,用于检查coroutine生命周期的状态。
coroutine.c
- Coroutine最常用的API是
coro_resume和coro_yield,它拖动了暂停和恢复的实际工作。 - 如果你已经有意识地浏览了上面的上下文切换API例子,那么我认为对于
coro_resume和coro_yield没有什么好解释的。它只是coro_yield跳转到coro_resume,反之亦然。除了第一次调用coro_resume,跳转到_coro_entry_point。 coro_new函数为处理程序和堆栈分配内存,然后填充处理程序成员。同样,getcontext和makecontext在这一点上应该是清楚的。如果没有,那么请重新阅读上面的上下文切换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帖子!