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

浏览: 372 发布日期: 2018-01-01 分类: c

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

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

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

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等几个简单的参数即可,其他的参数可以暂时忽略。其他的信息主要是用于共享栈模式,这个模式我们后续再讨论。

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中主要做的事情是保存当前协程栈的信息,然后再切换协程上下文信息的切换,其他共享栈的此处先不关心。

对应于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函数调用时的栈帧的变化。如果这一块不熟悉的话,需要自己先补一补课。

通过上图,我们把整个函数流程梳理一下,栈的维护是调用者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

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

//----- --------
// 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。关于协程的创建和管理就介绍到这里,后续将继续介绍协程的时间片以及共享栈的相关内容,敬请期待。

返回顶部