前面的文章Hook系统函数 中介绍了微信使用的协程库libco
,用于改造原有同步系统,利用协程实现系统的异步化,以支撑更大的并发,抵抗网络抖动带来的影响,同时代码层面更加简洁。git
libco库经过仅有的几个函数接口 co_create/co_resume/co_yield 再配合 co_poll,能够支持同步或者异步的写法,如线程库同样轻松。同时库里面提供了socket族函数的hook,使得后台逻辑服务几乎不用修改逻辑代码就能够完成异步化改造。
下面咱们来看一下libco
库是如何实现协程的。github
在了解微信是如何实现协程以前,先了解一下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
协程之于线程,至关于线程之于进程,一个进程能够包含多个线程,而一个线程中能够包含多个协程。线程中用于管理协程的结构体为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 ); }
初始化所作的事情主要是:数据结构
g_arrCoEnvPerThread
中对应于threadId的位置,这里的GetPid()
实际上是getThreadId()
,你们不要被这个函数名给误导了。Env_t
的main routine,用于运行该线程的主逻辑Env_t
信息初始化完毕后,将使用co_create_env
真正实现第一个协程的建立:
如今让咱们来看一下co_create_env
的实现步骤:并发
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_resume
的co_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); }
协程上下文信息的结构体中包括了保存了上次退出时的寄存器信息,以及栈信息。此处咱们只讨论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共同维护的。
push %ebp; mov %esp, %ebp
指令设置当前的栈底指针;并分配局部变量的栈空间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; }
这段代码主要是作了什么呢?
coctx_pfn_t
函数预留2个参数的大小,并4位地址对齐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 : ®s[7] + sizeof(void*) // esp=®[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
首先须要理解 leal
和movl
的区别,leal
是将算术值赋值给目标寄存器,movl 4(%esp)
则是将esp+4
算出来的值做为地址,取该地址的值赋值给目标寄存器。movl 4(%esp), %esp
是将cur_ctx
的地址赋值给esp
。
下面是恢复pend_ctx
中的寄存器信息到cpu
寄存器中
movl 4(%eax), %esp //parm b -> ®s[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
一块儿来阅读。
esp
指向pend_ctx
的地址regs
寄存器中的值恢复到cpu寄存器中,须要再看一下coctx_make
中的相关代码,regs[kEIP]
和regs[kESP]
恢复到eip
和esp
中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
最后的栈以下图所示:
理解这些代码须要了解栈帧的建立和恢复,以及一些汇编的简单代码,若有不了解,须要善用google。关于协程的建立和管理就介绍到这里,后续将继续介绍协程的时间片以及共享栈的相关内容,敬请期待。