微信协程库libco研究(二):协程的实现和管理

前面的文章Hook系统函数 中介绍了微信使用的协程库libco,用于改造原有同步系统,利用协程实现系统的异步化,以支撑更大的并发,抵抗网络抖动带来的影响,同时代码层面更加简洁。git

libco库经过仅有的几个函数接口 co_create/co_resume/co_yield 再配合 co_poll,能够支持同步或者异步的写法,如线程库同样轻松。同时库里面提供了socket族函数的hook,使得后台逻辑服务几乎不用修改逻辑代码就能够完成异步化改造。

下面咱们来看一下libco库是如何实现协程的。github

1. 协程相关结构体

在了解微信是如何实现协程以前,先了解一下stCoRoutime_t的数据结构,该类型定义了协程的相关变量,具体可参见如下代码的注释。编程

struct stCoRoutine_t
{
    stCoRoutineEnv_t *env;   //协程的运行context
    pfn_co_routine_t pfn;  // 协程的入口函数
    void *arg; // 入口函数的参数
    coctx_t ctx; // 保存了协程的上下文信息, 包括寄存器,栈的相关信息,用于恢复现场

    char cStart;
    char cEnd;
    char cIsMain;
    char cEnableSysHook;
    char cIsShareStack;

    void *pvEnv;

    //char sRunStack[ 1024 * 128 ];
    stStackMem_t* stack_mem;
    
    //save satck buffer while confilct on same stack_buffer;
    char* stack_sp; 
    unsigned int save_size;
    char* save_buffer;

    stCoSpec_t aSpec[1024];
};

该结构体中,咱们只须要记住stCoRoutineEnv_t,coctx_t,pfn_co_routine_t等几个简单的参数便可,其余的参数能够暂时忽略。其余的信息主要是用于共享栈模式,这个模式咱们后续再讨论。segmentfault

2. 协程的建立和运行

协程之于线程,至关于线程之于进程,一个进程能够包含多个线程,而一个线程中能够包含多个协程。线程中用于管理协程的结构体为stCoRoutineEnv_t,它在该线程中第一个协程建立的时候进行初始化。
每一个线程中都只有一个stCoRoutineEnv_t实例,线程能够经过该stCoRoutineEnv_t实例了解如今有哪些协程,哪一个协程正在运行,以及下一个运行的协程是哪一个。微信

struct stCoRoutineEnv_t
{
    stCoRoutine_t *pCallStack[ 128 ]; // 保存当前栈中的协程
    int iCallStackSize;  // 表示当前在运行的协程的下一个位置,即cur_co_runtine_index + 1
    stCoEpoll_t *pEpoll; //用于协程时间片切换

    //for copy stack log lastco and nextco
    stCoRoutine_t* pending_co;
    stCoRoutine_t* occupy_co;
};

pCallStack[ 128 ]这个表示协程栈最大为128,当协程切换时,栈顶的协程就被pop出来了,所以一个线程能够建立的协程数是能够超过128个的,你们大胆用起来。网络

void co_init_curr_thread_env()
{
    pid_t pid = GetPid();    
    g_arrCoEnvPerThread[ pid ] = (stCoRoutineEnv_t*)calloc( 1,sizeof(stCoRoutineEnv_t) );
    stCoRoutineEnv_t *env = g_arrCoEnvPerThread[ pid ];

    env->iCallStackSize = 0;
    struct stCoRoutine_t *self = co_create_env( env, NULL, NULL,NULL );
    self->cIsMain = 1;

    env->pending_co = NULL;
    env->occupy_co = NULL;

    coctx_init( &self->ctx );

    env->pCallStack[ env->iCallStackSize++ ] = self;

    stCoEpoll_t *ev = AllocEpoll();
    SetEpoll( env,ev );
}

初始化所作的事情主要是:数据结构

  1. 将Env_t信息保存在全局变量g_arrCoEnvPerThread中对应于threadId的位置,这里的GetPid()实际上是getThreadId(),你们不要被这个函数名给误导了。
  2. 建立一个空协程,被设置为当前Env_t的main routine,用于运行该线程的主逻辑
  3. 建立Epoll_t相关的信息,后续讨论时间片管理的时候再介绍

Env_t信息初始化完毕后,将使用co_create_env真正实现第一个协程的建立:
如今让咱们来看一下co_create_env的实现步骤:并发

  1. 初始化协程的栈信息
  2. 初始化stCoRoutine_t结构体中的运行函数相关信息,函数入口和函数参数等

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 )
    {
        coctx_make( &co->ctx,(coctx_pfn_t)CoRoutineFunc,co,0 ); // 将即将运行的协程设置上下文信息
        co->cStart = 1;
    }
    env->pCallStack[ env->iCallStackSize++ ] = co;
    co_swap( lpCurrRoutine, co );
}

co_swap中主要作的事情是保存当前协程栈的信息,而后再切换协程上下文信息的切换,其余共享栈的此处先不关心。socket

对应于co_resumeco_yield函数是为了让协程有主动放弃运行的权利。前面介绍到iCallStackSize指向 curIndex+1,所以,co_yield是将当前运行的协程的上下文信息保存到curr中,并切换到last中执行。

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);
}

3. 协程上下文的建立和运行

协程上下文信息的结构体中包括了保存了上次退出时的寄存器信息,以及栈信息。此处咱们只讨论32位系统的实现,你们对86的系统仍是比较熟悉一些。

struct coctx_t
{
#if defined(__i386__)
    void *regs[ 8 ];
#else
    void *regs[ 14 ];
#endif
    size_t ss_size;
    char *ss_sp;
    
};

在介绍协程上下文切换前,咱们必须了解c函数调用时的栈帧的变化。若是这一块不熟悉的话,须要本身先补一补课。
clipboard.png
经过上图,咱们把整个函数流程梳理一下,栈的维护是调用者Caller和被调用者Callee共同维护的。

  1. Caller将被调用函数的参数从右向左push到栈中;而后将被调用函数的下一条指令的地址push到栈中,即返回地址;使用call指令跳转到Callee函数中
  2. 使用 push %ebp; mov %esp, %ebp指令设置当前的栈底指针;并分配局部变量的栈空间
  3. Callee函数返回时,使用mov %ebp, %esp;pop %ebp;指令,将原来的ebp寄存器恢复,而后再调用ret指令(至关于pop %eip),并将返回地址pop到eip寄存器中

了解这些后,咱们先看一下协程上下文coctx_t的初始化:

int coctx_make( coctx_t *ctx,coctx_pfn_t pfn,const void *s,const void *s1 )
{
    //make room for coctx_param
    // 获取(栈顶 - param size)的指针,栈顶和sp指针之间用于保存函数参数
    char *sp = ctx->ss_sp + ctx->ss_size - sizeof(coctx_param_t);
    sp = (char*)((unsigned long)sp & -16L); // 用于16位对齐
 
    
    coctx_param_t* param = (coctx_param_t*)sp ;
    param->s1 = s;
    param->s2 = s1;

    memset(ctx->regs, 0, sizeof(ctx->regs));
    // 为何要 - sizeof(void*)呢? 用于保存返回地址
    ctx->regs[ kESP ] = (char*)(sp) - sizeof(void*);
    ctx->regs[ kEIP ] = (char*)pfn;

    return 0;
}

这段代码主要是作了什么呢?

  1. 先给coctx_pfn_t函数预留2个参数的大小,并4位地址对齐
  2. 将参数填入到预存的参数中
  3. regs[kEIP]中保存了pfn的地址,regs[kESP]中则保存了栈顶指针 - 4个字节的大小的地址。这预留的4个字节用于保存return address

如今咱们来看下协程切换的核心coctx_swap,这个函数是使用汇编实现的。主要分为保存原来的栈空间,并恢复现有的栈空间两个步骤。
先看一下执行汇编程序前的栈帧状况。esp寄存器指向return address
调用coctx_swap的栈分布

咱们先看一下当前栈空间的保存

//----- --------
// 32 bit
// | regs[0]: ret |
// | regs[1]: ebx |
// | regs[2]: ecx |
// | regs[3]: edx |
// | regs[4]: edi |
// | regs[5]: esi |
// | regs[6]: ebp |
// | regs[7]: eax |  = esp
coctx_swap:
    leal 4(%esp), %eax // eax = esp + 4
    movl 4(%esp), %esp  // esp = *(esp+4) = &cur_ctx
    leal 32(%esp), %esp // parm a : &regs[7] + sizeof(void*)  
                        // esp=&reg[7]+sizeof(void*) 
    pushl %eax // cur_ctx->regs[ESP] = %eax = returnAddress + 4 
    pushl %ebp // cur_ctx->regs[EBX] = %ebp
    pushl %esi // cur_ctx->regs[ESI] = %esi
    pushl %edi // cur_ctx->regs[EDI] = %edi
    pushl %edx // cur_ctx->regs[EDX] = %edx
    pushl %ecx // cur_ctx->regs[ECX] = %ecx
    pushl %ebx // cur_ctx->regs[EBX] = %ebx
    pushl -4(%eax) // cur_ctx->regs[EIP] = return address

首先须要理解 lealmovl的区别,leal是将算术值赋值给目标寄存器,movl 4(%esp)则是将esp+4算出来的值做为地址,取该地址的值赋值给目标寄存器。movl 4(%esp), %esp是将cur_ctx的地址赋值给esp

下面是恢复pend_ctx中的寄存器信息到cpu寄存器中

movl 4(%eax), %esp //parm b -> &regs[0]
                       // esp=&pend_ctx
    popl %eax  //%eax= pend_ctx->regs[EIP] = pfunc_t地址
    popl %ebx  //%ebx = pend_ctx->regs[EBX]
    popl %ecx  //%ecx = pend_ctx->regs[ECX]
    popl %edx  //%edx = pend_ctx->regs[EDX]
    popl %edi  //%edi = pend_ctx->regs[EDI]
    popl %esi  //%esi = pend_ctx->regs[ESI]
    popl %ebp  //%ebp = pend_ctx->regs[EBP]
    popl %esp  //%ebp = pend_ctx->regs[ESP] 即 (char*) sp - sizeof(void*)
    pushl %eax //set ret func addr
               // return address = %eax = pfunc_t地址
    xorl %eax, %eax
    ret // popl %eip 即跳转到pfunc_t地址执行

若是是第一次执行coctx_swap,则这部分汇编代码就须要结合前面coctx_make一块儿来阅读。

  1. 首先将esp指向pend_ctx的地址
  2. regs寄存器中的值恢复到cpu寄存器中,须要再看一下coctx_make中的相关代码,regs[kEIP]regs[kESP]恢复到eipesp
  3. ret指令至关于pop %eip,所以eip指向了pfunc_t地址,从而开始执行协程设置的入口函数。

若是是将原来已存在的协程恢复,则这部分代码则须要根据前面保存寄存器信息的汇编代码来一块儿阅读,将esp恢复到原始位置,并将 eip恢复成returnAddress

pushl %eax // cur_ctx->regs[ESP] = %eax = returnAddress + 4 
pushl -4(%eax) // cur_ctx->regs[EIP] = return address

最后的栈以下图所示:
协程恢复后的现场

4. 总结

理解这些代码须要了解栈帧的建立和恢复,以及一些汇编的简单代码,若有不了解,须要善用google。关于协程的建立和管理就介绍到这里,后续将继续介绍协程的时间片以及共享栈的相关内容,敬请期待。

相关文章
相关标签/搜索