“Subroutines are special cases of … coroutines.” — Donald Knuth
在前面的文章 动态链接之 Hook 系统函数 中我们提到过腾讯开源的一套用于支撑微信后端海量并发的 C++ 协程库 libco。相比于其他的 C++ 协程实现,libco 通过仅有的几个函数接口 co_create
,co_resume
以及 co_yield
配合 co_poll
,来支持同步或者异步的写法,从而实现对现有逻辑非侵入式的异步化改造。今天我们来详细分析一下 libco c++ 协程的实现。
(ps:写这个系列文章之前,我也没想到以后竟然会变成半个 libco 的使用者和维护者,当然这篇文章现在看来还是有一些问题的,后面再来纠正)
C/C++ 协程
首先需要声明的是,这里不打算花时间来介绍什么是协程,以及协程和线程有什么不同。如果对此有任何疑问,可以自行 google。与 Python 不同,C/C++ 语言本身是不能天然支持协程的。现有的 C++ 协程库均基于两种方案:利用汇编代码控制协程上下文的切换,以及利用操作系统提供的 API 来实现协程上下文切换。典型的例如:
- libco,Boost.context:基于汇编代码的上下文切换
- phxrpc:基于 ucontext/Boost.context 的上下文切换
- libmill:基于 setjump/longjump 的协程切换
一般而言,基于汇编的上下文切换要比采用系统调用的切换更加高效,这也是为什么 phxrpc 在使用 Boost.context 时要比使用 ucontext 性能更好的原因。关于 phxrpc 和 libmill 具体的协程实现方式,以后有时间再详细介绍。
libco 协程的创建和切换
在介绍 coroutine 的创建之前,我们先来熟悉一下 libco 中用来表示一个 coroutine 的数据结构,即定义在 co_routine_inner.h 中的 stCoRoutine_t
:
struct stCoRoutine_t
{
stCoRoutineEnv_t *env; // 协程运行环境
pfn_co_routine_t pfn; // 协程执行的逻辑函数
void *arg; // 函数参数
coctx_t ctx; // 保存协程的下文环境
...
char cEnableSysHook; // 是否运行系统 hook,即非侵入式逻辑
char cIsShareStack; // 是否在共享栈模式
void *pvEnv;
stStackMem_t* stack_mem; // 协程运行时的栈空间
char* stack_sp; // 用来保存协程运行时的栈空间
unsigned int save_size;
char* save_buffer;
};
我们暂时只需要了解表示协程的最简单的几个参数,例如协程运行环境,协程的上下文环境,协程运行的函数以及运行时栈空间。后面的 stack_sp
,save_size
和 save_buffer
与 libco 共享栈模式相关,有关共享栈的内容我们后续再说。
协程创建和运行
由于多个协程运行于一个线程内部的,因此当创建线程中的第一个协程时,需要初始化该协程所在的环境 stCoRoutineEnv_t
,这个环境是线程用来管理协程的,通过该环境,线程可以得知当前一共创建了多少个协程,当前正在运行哪一个协程,当前应当如何调度协程:
struct stCoRoutineEnv_t
{
stCoRoutine_t *pCallStack[ 128 ]; // 记录当前创建的协程
int iCallStackSize; // 记录当前一共创建了多少个协程
stCoEpoll_t *pEpoll; // 该线程的协程调度器
// 在使用共享栈模式拷贝栈内存时记录相应的 coroutine
stCoRoutine_t* pending_co;
stCoRoutine_t* occupy_co;
};
上述代码表明 libco 允许一个线程内最多创建 128 个协程,其中 pCallStack[iCallStackSize-1]
也就是栈顶的协程表示当前正在运行的协程。当调用函数 co_create
时,首先检查当前线程中的 coroutine env
结构是否创建。这里 libco 对于每个线程内的 stCoRoutineEnv_t 并没有使用 thread-local 的方式(例如gcc 内置的 __thread
,phxrpc采用这种方式)来管理,而是预先定义了一个大的数组,并通过对应的 PID 来获取其协程环境。:
static stCoRoutineEnv_t* g_arrCoEnvPerThread[204800]
stCoRoutineEnv_t *co_get_curr_thread_env()
{
return g_arrCoEnvPerThread[ GetPid() ];
}
初始化 stCoRoutineEnv_t
时主要完成以下几步:
- 为
stCoRoutineEnv_t
申请空间并且进行初始化,设置协程调度器pEpoll
。 - 创建一个空的 coroutine,初始化其上下文环境( 有关
coctx
在后文详细介绍 ),将其加入到该线程的协程环境中进行管理,并且设置其为 main coroutine。这个 main coroutine 用来运行该线程主逻辑。
当初始化完成协程环境之后,调用函数 co_create_env
来创建具体的协程,该函数初始化一个协程结构 stCoRoutine_t
,设置该结构中的各项字段,例如运行的函数 pfn
,运行时的栈地址等等。需要说明的就是,如果使用了非共享栈模式,则需要为该协程单独申请栈空间,否则从共享栈中申请空间。栈空间表示如下:
struct stStackMem_t
{
stCoRoutine_t* occupy_co; // 使用该栈的协程
int stack_size; // 栈大小
char* stack_bp; // 栈底指针,栈从高地址向低地址增长
char* stack_buffer; // 栈底
};
使用 co_create
创建完一个协程之后,将调用 co_resume
来将该协程激活运行:
void co_resume( stCoRoutine_t *co )
{
stCoRoutineEnv_t *env = co->env;
// 获取当前正在运行的协程的结构
stCoRoutine_t *lpCurrRoutine = env->pCallStack[ env->iCallStackSize - 1 ];
if( !co->cStart )
{
// 为将要运行的 co 布置上下文环境
coctx_make( &co->ctx,(coctx_pfn_t)CoRoutineFunc,co,0 );
co->cStart = 1;
}
env->pCallStack[ env->iCallStackSize++ ] = co; // 设置co为运行的线程
co_swap( lpCurrRoutine, co );
}
函数 co_swap
的作用类似于 Unix 提供的函数 swapcontext
:将当前正在运行的 coroutine 的上下文以及状态保存到结构 lpCurrRoutine
中,并且将 co
设置成为要运行的协程,从而实现协程的切换。co_swap
具体完成三项工作:
- 记录当前协程
curr
的运行栈的栈顶指针,通过char c; curr_stack_sp=&c
实现,当下次切换回curr
时,可以从该栈顶指针指向的位置继续,执行完curr
后可以顺利释放该栈。 - 处理共享栈相关的操作,并且调用函数
coctx_swap
来完成上下文环境的切换。注意执行完coctx_swap
之后,执行流程将跳到新的 coroutine 也就是 pending_co 中运行,后续的代码需要等下次切换回curr
时才会执行。 - 当下次切换回
curr
时,处理共享栈相关的操作。
对应于 co_resume
函数,协程主动让出执行权则调用 co_yield
函数。co_yield
函数调用了 co_yield_env
,将当前协程与当前线程中记录的其他协程进行切换:
void co_yield_env( stCoRoutineEnv_t *env )
{
stCoRoutine_t *last = env->pCallStack[ env->iCallStackSize - 2 ];
stCoRoutine_t *curr = env->pCallStack[ env->iCallStackSize - 1 ];
env->iCallStackSize--;
co_swap( curr, last);
}
前面我们已经提到过,pCallStack 栈顶所指向的即为当前正在运行的协程所对应的结构,因此该函数将 curr
取出来,并将当前正运行的协程上下文保存到该结构上,并切换到协程 last 上执行。接下来我们以 32-bit 的系统为例来分析 libco 是如何实现协程运行环境的切换的。
协程上下文的创建和切换
libco 使用结构 struct coctx_t
来表示一个协程的上下文环境:
struct coctx_t
{
#if defined(__i386__)
void *regs[ 8 ];
#else
void *regs[ 14 ];
#endif
size_t ss_size;
char *ss_sp;
};
可以看到,在 i386 的架构下,需要保存 8 个寄存器信息,以及栈指针和栈大小,究竟这 8 个寄存器如何保存,又是如何使用,需要配合后续的 coctx_swap
来理解。我们首先来回顾一下 Unix-like 系统的 stack frame layout,如果不能理解这个,那么剩下的内容就不必看了。
结合上图,我们需要知道关键的几点:
- 函数调用栈是调用者和被调用者共同负责布置的。Caller 将其参数从右向左反向压栈,再将调用后的返回地址压栈,然后将执行流程交给 Callee。
- 典型的编译器会将 Callee 函数汇编成为以
push %ebp; move %ebp, %esp; sub $esp N;
这种形式开头的汇编代码。这几句代码主要目的是为了方便 Callee 利用ebp
来访问调用者提供的参数以及自身的局部变量(如下图)。如果你写过操作系统或者编译器代码,这一点无需多言。如果你没写过,这里提供一篇 x86 Disassembly Stack Frames 供仔细理解。 - 当调用过程完成清除了局部变量以后,会执行
pop %ebp; ret
,这样指令会跳转到 RA 也就是返回地址上面执行。这一点也是实现协程切换的关键:我们只需要将指定协程的函数指针地址保存到 RA 中,当调用完coctx_swap
之后,会自动跳转到该协程的函数起始地址开始运行。
: :
| ... |
| para2 | [ebp + 12] (2nd argument)
| para1 | [ebp + 8] (1st argument)
| RA | [ebp + 4] (return address)
| FP | [ebp] (old ebp value)
| | [ebp - 4] (1st local variable)
: :
: :
frame
了解了这些,我们就来看一下协程上下文环境的初始化函数 coctx_make
:
int coctx_make( coctx_t *ctx, coctx_pfn_t pfn, const void *s, const void *s1 )
{
char *sp = ctx->ss_sp + ctx->ss_size - sizeof(coctx_param_t);
sp = (char*)((unsigned long)sp & -16L);
coctx_param_t* param = (coctx_param_t*)sp ;
param->s1 = s;
param->s2 = s1;
memset(ctx->regs, 0, sizeof(ctx->regs));
ctx->regs[ kESP ] = (char*)(sp) - sizeof(void*);
ctx->regs[ kEIP ] = (char*)pfn;
return 0;
}
这段代码应该比较好理解,首先为函数 coctx_pfn_t
预留 2 个参数的栈空间并对其到 16 字节,之后将实参设置到预留的栈上空间中。最后在 ctx
结构中填入相应的,其中记录 reg[kEIP] 返回地址为函数指针 pfn
,记录 reg[kESP] 为获得的栈顶指针 sp
减去一个指针长度,这个减去的空间是为返回地址 RA 预留的。当调用 coctx_swap
时,reg[kEIP] 会被放到返回地址 RA 的位置,待 coctx_swap
执行结束,自然会跳转到函数 pfn
处执行。
coctx_swap(ctx1, ctx2)
在 coctx_swap.S 中实现。这里可以看到,该函数并没有使用 push %ebp; move %ebp, %esp; sub $esp N;
开头,因此栈空间分布中不会出现 ebp
的位置。coctx_swap
函数主要分为两段,其首先将当前的上下文环境保存到 ctx1
结构中:
leal 4(%esp), %eax // eax = old_esp + 4
movl 4(%esp), %esp // 将 esp 的值设为 &ctx1(即ctx1的地址)
leal 32(%esp), %esp // esp = (char*)&ctx1 + 32
pushl %eax // ctx1->regs[EAX] = %eax
pushl %ebp // ctx1->regs[EBP] = %ebp
pushl %esi // ctx1->regs[ESI] = %esi
pushl %edi // ctx1->regs[EDI] = %edi
pushl %edx // ctx1->regs[EDX] = %edx
pushl %ecx // ctx1->regs[ECX] = %ecx
pushl %ebx // ctx1->regs[EBX] = %ebx
pushl -4(%eax) // ctx1->regs[EIP] = RA, 注意:%eax-4=%old_esp
这里需要注意指令 leal
和 movl
的区别。leal
将 eax 的值设置成为 esp 的值加 4,而 movl
将 esp 的值设为 esp+4 所指向的内存上的值,也就是参数 ctx1 的地址。之后该函数将 ctx2
中记录的上下文恢复到 CPU 寄存器中,并跳转到其函数地址处运行:
movl 4(%eax), %esp // 将 esp 的值设为 &ctx2(即ctx2的地址)
popl %eax // %eax = ctx1->regs[EIP],也就是 &pfn
popl %ebx // %ebx = ctx1->regs[EBP]
popl %ecx // %ecx = ctx1->regs[ECX]
popl %edx // %edx = ctx1->regs[EDX]
popl %edi // %edi = ctx1->regs[EDI]
popl %esi // %esi = ctx1->regs[ESI]
popl %ebp // %ebp = ctx1->regs[EBP]
popl %esp // %esp = ctx1->regs[ESP],即(char*)(sp) - sizeof(void*)
pushl %eax // RA = %eax = &pfn,注意此时esp已经指向了新的esp
xorl %eax, %eax // reset eax
ret
上面的代码看起来可能有些绕:
- 首先 line 1 将 esp 设置为参数 ctx2 的地址,后续的 popl 操作均在 ctx2 的内存空间上执行。
- line 2-9 将
ctx2->regs[]
中的内容恢复到相应的寄存器中。还记得在前面coctx_make
中设置了regs[EIP]
和regs[ESP]
吗?这里刚好就对应恢复了相应的值。 - 当执行完 line 9 之后,esp 已经指向了
ctx2
中新的栈顶指针,由于在coctx_make
中预留了一个指针长度的 RA 空间,line 10 刚好将新的函数指针&pfn
设置到该 RA 上。 - 最后执行
ret
指令时,函数流程将跳到pfn
处执行。这样,整个协程上下文的切换就完成了。
这一部分的内容如果不了解汇编代码,也不熟悉 stack frame 和函数调用的过程的话,理解起来可能会比较费劲。有关协程的实现就分析到这里,关于 libco 的共享栈模式,以及协程是如何通过 epoll 进行管理的,我们在后``文中分析。