libco 分析(上):协程的实现

Posted by YuanBao on July 10, 2017

“Subroutines are special cases of … coroutines.” — Donald Knuth

在前面的文章 动态链接之 Hook 系统函数 中我们提到过腾讯开源的一套用于支撑微信后端海量并发的 C++ 协程库 libco。相比于其他的 C++ 协程实现,libco 通过仅有的几个函数接口 co_createco_resume 以及 co_yield 配合 co_poll,来支持同步或者异步的写法,从而实现对现有逻辑非侵入式的异步化改造。今天我们来详细分析一下 libco c++ 协程的实现。

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_spsave_sizesave_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 内置的 __threadphxrpc采用这种方式)来管理,而是预先定义了一个大的数组,并通过对应的 PID 来获取其协程环境。:

static stCoRoutineEnv_t* g_arrCoEnvPerThread[204800]
stCoRoutineEnv_t *co_get_curr_thread_env()
{
	return g_arrCoEnvPerThread[ GetPid() ];
}

初始化 stCoRoutineEnv_t 时主要完成以下几步:

  1. stCoRoutineEnv_t 申请空间并且进行初始化,设置协程调度器 pEpoll
  2. 创建一个空的 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 具体完成三项工作:

  1. 记录当前协程 curr 的运行栈的栈顶指针,通过 char c; curr_stack_sp=&c 实现,当下次切换回 curr时,可以从该栈顶指针指向的位置继续,执行完 curr 后可以顺利释放该栈。
  2. 处理共享栈相关的操作,并且调用函数 coctx_swap 来完成上下文环境的切换。注意执行完 coctx_swap 之后,执行流程将跳到新的 coroutine 也就是 pending_co 中运行,后续的代码需要等下次切换回 curr 时才会执行。
  3. 当下次切换回 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,如果不能理解这个,那么剩下的内容就不必看了。

结合上图,我们需要知道关键的几点:

  1. 函数调用栈是调用者和被调用者共同负责布置的。Caller 将其参数从右向左反向压栈,再将调用后的返回地址压栈,然后将执行流程交给 Callee。
  2. 典型的编译器会将 Callee 函数汇编成为以 push %ebp; move %ebp, %esp; sub $esp N; 这种形式开头的汇编代码。这几句代码主要目的是为了方便 Callee 利用 ebp 来访问调用者提供的参数以及自身的局部变量(如下图)。如果你写过操作系统或者编译器代码,这一点无需多言。如果你没写过,这里提供一篇 x86 Disassembly Stack Frames 供仔细理解。
  3. 当调用过程完成清除了局部变量以后,会执行 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   

这里需要注意指令 lealmovl 的区别。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

上面的代码看起来可能有些绕:

  1. 首先 line 1 将 esp 设置为参数 ctx2 的地址,后续的 popl 操作均在 ctx2 的内存空间上执行。
  2. line 2-9 将 ctx2->regs[] 中的内容恢复到相应的寄存器中。还记得在前面 coctx_make 中设置了 regs[EIP]regs[ESP] 吗?这里刚好就对应恢复了相应的值。
  3. 当执行完 line 9 之后,esp 已经指向了 ctx2 中新的栈顶指针,由于在 coctx_make 中预留了一个指针长度的 RA 空间,line 10 刚好将新的函数指针 &pfn 设置到该 RA 上。
  4. 最后执行 ret 指令时,函数流程将跳到 pfn 处执行。这样,整个协程上下文的切换就完成了。

这一部分的内容如果不了解汇编代码,也不熟悉 stack frame 和函数调用的过程的话,理解起来可能会比较费劲。有关协程的实现就分析到这里,关于 libco 的共享栈模式,以及协程是如何通过 epoll 进行管理的,我们在后``文中分析。