前面的文章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 );
}
初始化所做的事情主要是:
- 将Env_t信息保存在全局变量
g_arrCoEnvPerThread
中对应于threadId的位置,这里的GetPid()
其实是getThreadId()
,大家不要被这个函数名给误导了。 - 创建一个空协程,被设置为当前
Env_t
的main routine,用于运行该线程的主逻辑 - 创建Epoll_t相关的信息,后续讨论时间片管理的时候再介绍
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
中主要做的事情是保存当前协程栈的信息,然后再切换协程上下文信息的切换,其他共享栈的此处先不关心。
对应于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);
}
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共同维护的。
- Caller将被调用函数的参数从右向左push到栈中;然后将被调用函数的下一条指令的地址push到栈中,即返回地址;使用call指令跳转到Callee函数中
- 使用
push %ebp; mov %esp, %ebp
指令设置当前的栈底指针;并分配局部变量的栈空间 - 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;
}
这段代码主要是做了什么呢?
- 先给
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
最后的栈如下图所示:
4. 总结
理解这些代码需要了解栈帧的创建和恢复,以及一些汇编的简单代码,如有不了解,需要善用google。关于协程的创建和管理就介绍到这里,后续将继续介绍协程的时间片以及共享栈的相关内容,敬请期待。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。