协程与线程(进程)[1]都是模拟多任务(routine)并发执行的软件实现。操作系统线程在一个 CPU 线程中模拟并发执行多个任务,而协程(coroutine)在一个操作系统线程中模拟并发执行多个任务。 因此协程也被称为“用户态线程”、“轻量级线程”。之所以说“模拟并发执行”,是因为任务实际并没有同时运行,而是通过来回切换实现的。
线程切换由操作系统负责,而协程切换通常由程序员直接控制。程序员通过 resume/yield 操作控制协程切换。resume 操作唤醒一个指定协程;yield 操作挂起当前协程,切换回唤醒它的协程。如果你用过 Lua 的协程,就会很熟悉这套流程。不过 Lua 是基于 Lua 虚拟机(LVM)的脚本语言,它只需要 LVM 中执行“虚拟的”上下文切换。本文介绍如何用 C 语言(和一点点汇编)实现一个 native 协程,执行真正的上下文切换。这个实现非常简单,总共不到 200 行代码。我参考了 libco 的实现。本文的完整代码见 toy-coroutine。
所谓的上下文,就是一段程序之前做了什么,接下来要做什么,以及做事情过程的中间产物。例如我们有函数 f。f 需要知道下一个指令是什么才能接着往下执行,便是“接下来要做什么”。f 函数还需要知道之前是谁调用了它,以便把结果返回给调用者,便是“之前做了什么”。在 f 函数执行过程中,局部变量要存好(不能被写坏),接下来的指令才能正确执行。这便是“过程的中间产物”。
在 x86-64 下,“之前做了什么” 存储在栈里。函数调用会执行 call 指令,把当前函数的下一个指令的地址压入栈顶,然后再跳转到被调用函数。被调用函数返回时执行 ret 指令,从栈顶取出调用者的返回点地址,然后跳转到返回点。因此栈上存有所有前序调用者的返回点地址。
函数的局部变量通常储存在 16 个通用寄存器中,如果寄存器不够用,就存在栈里(只要在函数返回前将它们弹出,让栈顶是返回点地址即可)。函数调用的参数也是局部变量,存在约定的 6 个通用寄存器里。如果不够用,也存在栈里。
至于“接下来要做什么”,其实也在栈里。上下文切换不过是调用一个函数,调用者在调用它之前已经把下一个指令的地址压栈了。当上下文切换函数返回,ret 指令自然会跳转到接下来要执行的指令。所以上下文就是 16 个通用寄存器 + 栈。
所有的协程共享同一个 CPU,也就共享同样的 16 个通用寄存器。如果我们要把 A 协程切换成 B 协程,就要把当前 16 个通用寄存器的值存在 A 协程的数据结构里;然后再从 B 协程的数据结构里取出 B 协程的寄存器的值,写回通用寄存器中。我们还要处理栈。不过栈与寄存器不同,x86-64 规定 %rsp 寄存器(也是通用寄存器之一)存的值便是栈顶的地址。不同的协程不必共享栈,它们可以分配各自的栈,上下文切换时将 %rsp 指向各自的栈顶即可。
实际上我们不必存储全部的 16 个通用寄存器,它们有些是暂存寄存器(Scratch Registers),是允许被写坏的。这些寄存器的值可能在执行一次函数调用后就变了(被被调用函数写坏的)。编译器也不会在暂存寄存器里存储函数调用后还要用的值。参考 libco 的实现,我们存储 13 个寄存器:
1 | |
有些寄存器有特殊的用途。这里我们只需要知道这三个:
%rsp: 栈寄存器,指向栈顶。%rdi, %rsi: 第一参数寄存器和第二参数寄存器,调用函数前将第一个参数存在 %rdi 里,第二个存在 %rsi 里(剩下的四个依次是 %rdx, %rcx, %r8, %r9, 不过这里我们用不上),然后执行 call 指令。接着我们定义一个函数做上下文切换,把当前通用寄存器的值保存在 curr 中,再把 next 中保存的寄存器的值写回各个通用寄存器。
1 | |
Emm,这个函数没法用 C 语言实现,我们得用到一点点汇编了。其实非常简单,我们只需要用 movq 指令存取寄存器。代码如下:
1 | |
不懂汇编没关系(其实我也不是很懂),只需要知道 movq 指令将第一个操作数的值复制到第二个操作数中。% 开头的标识符为寄存器。%rsp 这样不带括号的,表示存取寄存器的值。(%rdi) 这种带括号的,表示去内存里存取地址为 %rdi 的数据。如果括号前面有数字几,就表示这个地址要加几。movq 存取数据的长度为 8 字节,寄存器的长度也是 8 字节。
还记得前面说过,%rdi 是第一个参数,%rsi 是第二个参数吗?所以 %rdi 就是 struct co_context *curr。96(%rdi) 就是 curr->regs[12],88(%rdi) 就是 curr->regs[11],……,(%rdi) 就是 curr->regs[0]。上半部分把 13 个通用寄存器的值全部存到了 curr 里。同理 %rsi 就是 struct co_context *next。(%rsi) 就是 next->regs[0]、8(%rsi) 就是 next->regs[1],依次类推。于是下半部分把 next 中保存的寄存器的值写回寄存器中。最后执行 ret 指令返回。
注意 29 行写入 %rsp 的值就是上次挂起时第 4 行保存的值,这个值我们原封未动,也没有做任何栈操作。因此最后 ret 返回时,栈顶就是 co_ctx_swap 的调用者设置的返回点地址。一个协程调用 co_ctx_swap 将自己挂起,便陷入沉睡。当 co_ctx_swap 返回之时,便是其它协程调用 co_ctx_swap 将它唤醒之时。此时寄存器被还原、栈被还原、也回到了返回点。它便知道自己之前做了什么、接下来要做什么、中间产物是怎样的。
struct co_context 仅存储协程的上下文。我们还需要维护给协程分配的栈空间、记录入口函数地址等。我们定义 struct coroutine 表示协程对象。
1 | |
co_new 创建一个新协程,接受两个参数:start 协程入口函数指针,和 stack_size 栈大小;这类似于 pthread_create。co_new 分配协程的栈空间并设置好各个字段。
要把主线程切换到我们新创建的协程,这里有两个问题。一是主线程并不是一个协程,新协程跟谁交换上下文呢?二是新创建的协程的上下文是空的(19 行),切换过去肯定跑不起来。
第一个问题很简单:创建一个就行。因为主线程已经跑起来了,要切换到新协程,主线程只需要一个“容器”把它的上下文装进去。直接执行 main = co_new(NULL, 0) 创建主协程,调用 co_ctx_swap(&main->ctx, &new->ctx) 便可切换到新协程。此时主线(协)程的上下文保存在 main 中,当新协程反向调用 co_ctx_swap(&new->ctx, &main->ctx),便又切换回主协程了。
为了解决第二个问题,我们需要对新协程初始化。co_ctx_swap 将新协程的上下文复制到 CPU 后,执行 ret 返回栈顶记录的地址。因此我们要将栈顶置为协程入口函数的地址,这样在 co_ctx_swap 返回后便跳转到协程入口函数了。
1 | |
因为 x86 的栈是从高地址向低地址增长的,初始栈为空,所以栈顶应该指向 co->stack 的最末尾。又因为 x86 的栈必须 16 字节对齐,所以执行 (intptr_t)sp & -16LL(-16 低 4 位为 0,其它都为 1)得到栈顶地址。然后将栈顶置为 co->start,也就是入口函数的地址。最后我们把保存的 rsp 寄存器的值设置为栈顶地址,这个值会在 co_ctx_swap 被复制到寄存器 %rsp 中。
现在我们的协程已经可以跑起来了。写一个简单的例子试试:
1 | |
把上面所有的 C 代码复制到文件 co.c,汇编代码存为 co.S,然后执行 gcc -o co co.c co.S 编译,运行试试!
现在的协程虽然可以跑,但是使用起来很不方便,需要手动交换上下文,也容易出错。我们需要实现 resume/yield 操作。resume 操作唤醒指定协程,也就是当前协程与指定协程交换。yield 挂起当前协程,将当前协程与上次唤醒它的协程交换。因此我们需要记录当前运行的协程;而对于每个协程,要保存唤醒它的协程的指针。
协程切换要遵循这几条规则:
基于此,我们给协程定义五个状态:
1 | |
我们使用全局变量 g_curr_co 记录当前协程。每个协程还要记录当前状态和唤醒自己的协程。
1 | |
接着实现 resume 操作和 yield 操作。根据上面描述的思路,实现起来很容易。
1 | |
除主协程外的协程结束运行时一定要执行 yield 操作将自己切出,否则它不知道该返回到哪儿。为了不让使用者手动执行这个操作,我们将协程入口函数封装一层。
1 | |
这样我们的协程用起来就更方便了:
1 | |
resume/yield 可以用于传递参数。运行上面的例子,我们发现 co_yield 返回之时便是其它协程调用 co_resume 之时;而 co_resume 返回之时又是其它协程调用 co_yield 之时。因此 resume 操作接受参数,传递给 yield 返回;yield 操作接受参数,传递给 resume 返回。这样方便在协程之间传递数据。
我们在 struct coroutine 中新增一个 data 字段用于传递参数。协程切换时,如果要给目标协程传递参数,就对目标协程的 data 字段赋值。协程切换后,就能从 data 字段中取出上一个协程传递的参数。
1 | |
我们重新定义协程入口函数,让它接受参数和返回值。第一次 resume 的参数传给入口函数;入口函数的返回值在最后一次 yield 时传出去。
1 | |
现在,我们的协程库已经完全实现了。我们可以写一些例子测试一下。比如说我们可以创建一个源源不断生成以 n 开头的自然数的协程:
1 | |
运行结果就是
1 | |
这个协程就是一个无限流。我们还可以写一个将两个无限流逐项相加的协程:
1 | |
然后将 0 开头的自然数流与 1 开头的自然数流逐项相加,得到奇数无限流(0 + 1 = 1, 1 + 2 = 3, 2 + 3 = 5, …)
1 | |
运行结果就是
1 | |
当然还有更好玩的。我们可以实现一个斐波那契数列生成器。斐波那契数列可以自我定义:令 f(i) 是以第 i 项开头的斐波那契数列,f(a) + f(b) 表示将两个数列逐项相加,那么如下所示,f(2) = f(0) + f(1)。
1 | |
所以我们可以这样做
1 | |
运行结果便是斐波那契数列:
1 | |
不过这种写法会创建大量协程,性能很低。仅供演示(炫技)。
协程与线程(进程)[1]都是模拟多任务(routine)并发执行的软件实现。操作系统线程在一个 CPU 线程中模拟并发执行多个任务,而协程(coroutine)在一个操作系统线程中模拟并发执行多个任务。 因此协程也被称为“用户态线程”、“轻量级线程”。之所以说“模拟并发执行”,是因为任务实际并没有同时运行,而是通过来回切换实现的。
线程切换由操作系统负责,而协程切换通常由程序员直接控制。程序员通过 resume/yield 操作控制协程切换。resume 操作唤醒一个指定协程;yield 操作挂起当前协程,切换回唤醒它的协程。如果你用过 Lua 的协程,就会很熟悉这套流程。不过 Lua 是基于 Lua 虚拟机(LVM)的脚本语言,它只需要 LVM 中执行“虚拟的”上下文切换。本文介绍如何用 C 语言(和一点点汇编)实现一个 native 协程,执行真正的上下文切换。这个实现非常简单,总共不到 200 行代码。我参考了 libco 的实现。本文的完整代码见 toy-coroutine。
所谓的上下文,就是一段程序之前做了什么,接下来要做什么,以及做事情过程的中间产物。例如我们有函数 f。f 需要知道下一个指令是什么才能接着往下执行,便是“接下来要做什么”。f 函数还需要知道之前是谁调用了它,以便把结果返回给调用者,便是“之前做了什么”。在 f 函数执行过程中,局部变量要存好(不能被写坏),接下来的指令才能正确执行。这便是“过程的中间产物”。
在 x86-64 下,“之前做了什么” 存储在栈里。函数调用会执行 call 指令,把当前函数的下一个指令的地址压入栈顶,然后再跳转到被调用函数。被调用函数返回时执行 ret 指令,从栈顶取出调用者的返回点地址,然后跳转到返回点。因此栈上存有所有前序调用者的返回点地址。
函数的局部变量通常储存在 16 个通用寄存器中,如果寄存器不够用,就存在栈里(只要在函数返回前将它们弹出,让栈顶是返回点地址即可)。函数调用的参数也是局部变量,存在约定的 6 个通用寄存器里。如果不够用,也存在栈里。
至于“接下来要做什么”,其实也在栈里。上下文切换不过是调用一个函数,调用者在调用它之前已经把下一个指令的地址压栈了。当上下文切换函数返回,ret 指令自然会跳转到接下来要执行的指令。所以上下文就是 16 个通用寄存器 + 栈。
所有的协程共享同一个 CPU,也就共享同样的 16 个通用寄存器。如果我们要把 A 协程切换成 B 协程,就要把当前 16 个通用寄存器的值存在 A 协程的数据结构里;然后再从 B 协程的数据结构里取出 B 协程的寄存器的值,写回通用寄存器中。我们还要处理栈。不过栈与寄存器不同,x86-64 规定 %rsp 寄存器(也是通用寄存器之一)存的值便是栈顶的地址。不同的协程不必共享栈,它们可以分配各自的栈,上下文切换时将 %rsp 指向各自的栈顶即可。
实际上我们不必存储全部的 16 个通用寄存器,它们有些是暂存寄存器(Scratch Registers),是允许被写坏的。这些寄存器的值可能在执行一次函数调用后就变了(被被调用函数写坏的)。编译器也不会在暂存寄存器里存储函数调用后还要用的值。参考 libco 的实现,我们存储 13 个寄存器:
1 | |
有些寄存器有特殊的用途。这里我们只需要知道这三个:
%rsp: 栈寄存器,指向栈顶。%rdi, %rsi: 第一参数寄存器和第二参数寄存器,调用函数前将第一个参数存在 %rdi 里,第二个存在 %rsi 里(剩下的四个依次是 %rdx, %rcx, %r8, %r9, 不过这里我们用不上),然后执行 call 指令。接着我们定义一个函数做上下文切换,把当前通用寄存器的值保存在 curr 中,再把 next 中保存的寄存器的值写回各个通用寄存器。
1 | |
Emm,这个函数没法用 C 语言实现,我们得用到一点点汇编了。其实非常简单,我们只需要用 movq 指令存取寄存器。代码如下:
1 | |
不懂汇编没关系(其实我也不是很懂),只需要知道 movq 指令将第一个操作数的值复制到第二个操作数中。% 开头的标识符为寄存器。%rsp 这样不带括号的,表示存取寄存器的值。(%rdi) 这种带括号的,表示去内存里存取地址为 %rdi 的数据。如果括号前面有数字几,就表示这个地址要加几。movq 存取数据的长度为 8 字节,寄存器的长度也是 8 字节。
还记得前面说过,%rdi 是第一个参数,%rsi 是第二个参数吗?所以 %rdi 就是 struct co_context *curr。96(%rdi) 就是 curr->regs[12],88(%rdi) 就是 curr->regs[11],……,(%rdi) 就是 curr->regs[0]。上半部分把 13 个通用寄存器的值全部存到了 curr 里。同理 %rsi 就是 struct co_context *next。(%rsi) 就是 next->regs[0]、8(%rsi) 就是 next->regs[1],依次类推。于是下半部分把 next 中保存的寄存器的值写回寄存器中。最后执行 ret 指令返回。
注意 29 行写入 %rsp 的值就是上次挂起时第 4 行保存的值,这个值我们原封未动,也没有做任何栈操作。因此最后 ret 返回时,栈顶就是 co_ctx_swap 的调用者设置的返回点地址。一个协程调用 co_ctx_swap 将自己挂起,便陷入沉睡。当 co_ctx_swap 返回之时,便是其它协程调用 co_ctx_swap 将它唤醒之时。此时寄存器被还原、栈被还原、也回到了返回点。它便知道自己之前做了什么、接下来要做什么、中间产物是怎样的。
struct co_context 仅存储协程的上下文。我们还需要维护给协程分配的栈空间、记录入口函数地址等。我们定义 struct coroutine 表示协程对象。
1 | |
co_new 创建一个新协程,接受两个参数:start 协程入口函数指针,和 stack_size 栈大小;这类似于 pthread_create。co_new 分配协程的栈空间并设置好各个字段。
要把主线程切换到我们新创建的协程,这里有两个问题。一是主线程并不是一个协程,新协程跟谁交换上下文呢?二是新创建的协程的上下文是空的(19 行),切换过去肯定跑不起来。
第一个问题很简单:创建一个就行。因为主线程已经跑起来了,要切换到新协程,主线程只需要一个“容器”把它的上下文装进去。直接执行 main = co_new(NULL, 0) 创建主协程,调用 co_ctx_swap(&main->ctx, &new->ctx) 便可切换到新协程。此时主线(协)程的上下文保存在 main 中,当新协程反向调用 co_ctx_swap(&new->ctx, &main->ctx),便又切换回主协程了。
为了解决第二个问题,我们需要对新协程初始化。co_ctx_swap 将新协程的上下文复制到 CPU 后,执行 ret 返回栈顶记录的地址。因此我们要将栈顶置为协程入口函数的地址,这样在 co_ctx_swap 返回后便跳转到协程入口函数了。
1 | |
因为 x86 的栈是从高地址向低地址增长的,初始栈为空,所以栈顶应该指向 co->stack 的最末尾。又因为 x86 的栈必须 16 字节对齐,所以执行 (intptr_t)sp & -16LL(-16 低 4 位为 0,其它都为 1)得到栈顶地址。然后将栈顶置为 co->start,也就是入口函数的地址。最后我们把保存的 rsp 寄存器的值设置为栈顶地址,这个值会在 co_ctx_swap 被复制到寄存器 %rsp 中。
现在我们的协程已经可以跑起来了。写一个简单的例子试试:
1 | |
把上面所有的 C 代码复制到文件 co.c,汇编代码存为 co.S,然后执行 gcc -o co co.c co.S 编译,运行试试!
现在的协程虽然可以跑,但是使用起来很不方便,需要手动交换上下文,也容易出错。我们需要实现 resume/yield 操作。resume 操作唤醒指定协程,也就是当前协程与指定协程交换。yield 挂起当前协程,将当前协程与上次唤醒它的协程交换。因此我们需要记录当前运行的协程;而对于每个协程,要保存唤醒它的协程的指针。
协程切换要遵循这几条规则:
基于此,我们给协程定义五个状态:
1 | |
我们使用全局变量 g_curr_co 记录当前协程。每个协程还要记录当前状态和唤醒自己的协程。
1 | |
接着实现 resume 操作和 yield 操作。根据上面描述的思路,实现起来很容易。
1 | |
除主协程外的协程结束运行时一定要执行 yield 操作将自己切出,否则它不知道该返回到哪儿。为了不让使用者手动执行这个操作,我们将协程入口函数封装一层。
1 | |
这样我们的协程用起来就更方便了:
1 | |
resume/yield 可以用于传递参数。运行上面的例子,我们发现 co_yield 返回之时便是其它协程调用 co_resume 之时;而 co_resume 返回之时又是其它协程调用 co_yield 之时。因此 resume 操作接受参数,传递给 yield 返回;yield 操作接受参数,传递给 resume 返回。这样方便在协程之间传递数据。
我们在 struct coroutine 中新增一个 data 字段用于传递参数。协程切换时,如果要给目标协程传递参数,就对目标协程的 data 字段赋值。协程切换后,就能从 data 字段中取出上一个协程传递的参数。
1 | |
我们重新定义协程入口函数,让它接受参数和返回值。第一次 resume 的参数传给入口函数;入口函数的返回值在最后一次 yield 时传出去。
1 | |
现在,我们的协程库已经完全实现了。我们可以写一些例子测试一下。比如说我们可以创建一个源源不断生成以 n 开头的自然数的协程:
1 | |
运行结果就是
1 | |
这个协程就是一个无限流。我们还可以写一个将两个无限流逐项相加的协程:
1 | |
然后将 0 开头的自然数流与 1 开头的自然数流逐项相加,得到奇数无限流(0 + 1 = 1, 1 + 2 = 3, 2 + 3 = 5, …)
1 | |
运行结果就是
1 | |
当然还有更好玩的。我们可以实现一个斐波那契数列生成器。斐波那契数列可以自我定义:令 f(i) 是以第 i 项开头的斐波那契数列,f(a) + f(b) 表示将两个数列逐项相加,那么如下所示,f(2) = f(0) + f(1)。
1 | |
所以我们可以这样做
1 | |
运行结果便是斐波那契数列:
1 | |
不过这种写法会创建大量协程,性能很低。仅供演示(炫技)。