腾讯开源的Libco协程库,以前看过部分源码,所有的协程都用数组模拟栈表示,里面使用到的技术点有hook系统函数,时间轮定时器,epoll,共享栈等,但没有协程池,当协程完成任务时,这里只在example中用了vector保存。
涉及的源文件不是很多,从example_echosvr.cpp中开始分析,echo例子最简单(读写和连接),只考虑单进程单线程情况,只分析原理,有些细节比如如何实现闭包,类似lua中的closure/upvalue,不会分析,不过lua中是会存储相关的索引,有时间会分析下lua中实现此功能的原理;只考虑x86-64架构。
struct stCoRoutine_t
表示的是协程对象的基本数据:
49 struct stCoRoutine_t
50 {
51 stCoRoutineEnv_t *env;
52 pfn_co_routine_t pfn;
53 void *arg;
54 coctx_t ctx;
55
56 char cStart;
57 char cEnd;
58 char cIsMain;
59 char cEnableSysHook;
60 char cIsShareStack;
61
62 void *pvEnv;
65 stStackMem_t* stack_mem;
67
68 //save satck buffer while confilct on same stack_buffer;
69 char* stack_sp;
70 unsigned int save_size;
71 char* save_buffer;
72
73 stCoSpec_t aSpec[1024];
75 };
其中ctx
是协程的上下文:
28 struct coctx_t
29 {
30 #if defined(__i386__)
31 void *regs[ 8 ];
32 #else
33 void *regs[ 14 ];
34 #endif
35 size_t ss_size;
36 char *ss_sp;
37
38 };
这些寄存器regs
和栈指针,大小会在后面分析;pfn
是协程运行入口函数;cIsShareStack
表示是否启用共享栈;其他几个变量在分析实现时会解释作用。
这里没有使用ucontext_t
,而是使用的struct coctx_t
,可以对比着看:
typedef struct ucontext_t {
struct ucontext_t *uc_link;
sigset_t uc_sigmask;
stack_t uc_stack;
mcontext_t uc_mcontext;
...
} ucontext_t;
以上定义在
文件中,uc_sigmask
是信号集合typedef unsigned long int __sigset_t
,这里不做过多说明,uc_link
表示后继协程的上下文,在另外两篇分析协程的博客包括本篇,uc_link
表示的是主协程即main
;uc_stack
表示栈的信息即栈指针,ss_size
栈大小 ,ss_flags
暂时不是很清楚作用:
26 typedef struct
27 {
28 void *ss_sp;
29 size_t ss_size;
30 int ss_flags;
31 } stack_t
uc_mcontext
表示寄存器:
118 typedef struct
119 {
120 gregset_t __ctx(gregs);
//more code...
122 } mcontext_t;
38 /* Container for all general registers. */
39 typedef greg_t gregset_t[__NGREG];
关于ucontext_t
实现原理和四个操作函数会在链接中给出参考资料,可能这里也会做一些说明,综上,这个结构和上面声明的struct coctx_t
整体上差不多,作用也是。
struct stCoRoutineEnv_t
表示的是整个环境信息:
51 struct stCoRoutineEnv_t
52 {
53 stCoRoutine_t *pCallStack[ 128 ];
54 int iCallStackSize;
55 stCoEpoll_t *pEpoll;
56
57 //for copy stack log lastco and nextco
58 stCoRoutine_t* pending_co;
59 stCoRoutine_t* occupy_co;
60 };
pending_co
和occupy_co
表示切出和切入协程;pCallStack
表示有哪些协程在等待,最多只支持128个,可调整。
312 struct stCoEpoll_t
313 {
314 int iEpollFd;
315 static const int _EPOLL_SIZE = 1024 * 10;
316
317 struct stTimeout_t *pTimeout;
319 struct stTimeoutItemLink_t *pstTimeoutList;
321 struct stTimeoutItemLink_t *pstActiveList;
323 co_epoll_res *result;
325 };
struct stCoEpoll_t
这个结构体用于等待事件和超时,所有的协程对象最后都会挂在上面,作用和Pebble协程中的声明类似,可参考另外一篇。
下面以例子来分析整个工作过程,假设一个task
如下声明:
45 struct task_t
46 {
47 stCoRoutine_t *co;
48 int fd;
49 };
创建一个task
并运行,这里例举有两个task
,不包括主协程:
243 task_t * task = (task_t*)calloc( 1,sizeof(task_t) );
244 task->fd = -1;
245
246 co_create( &(task->co),NULL,readwrite_routine,task );
247 co_resume( task->co );
readwrite_routine
为协程的入口函数;task
是函数的参数:
521 int co_create( stCoRoutine_t **ppco,const stCoRoutineAttr_t *attr,pfn_co_routine_t pfn,void *arg )
522 {
523 if( !co_get_curr_thread_env() )
524 {
525 co_init_curr_thread_env();
526 }
527 stCoRoutine_t *co = co_create_env( co_get_curr_thread_env(), attr, pfn,arg );
528 *ppco = co;
529 return 0;
530 }
704 static stCoRoutineEnv_t* g_arrCoEnvPerThread[ 204800 ] = { 0 };
705 void co_init_curr_thread_env()
706 {
707 pid_t pid = GetPid();
708 g_arrCoEnvPerThread[ pid ] = (stCoRoutineEnv_t*)calloc( 1,sizeof(stCoRoutineEnv_t) );
709 stCoRoutineEnv_t *env = g_arrCoEnvPerThread[ pid ];
710
711 env->iCallStackSize = 0;
712 struct stCoRoutine_t *self = co_create_env( env, NULL, NULL,NULL );
713 self->cIsMain = 1;
714
715 env->pending_co = NULL;
716 env->occupy_co = NULL;
717
718 coctx_init( &self->ctx );
719
720 env->pCallStack[ env->iCallStackSize++ ] = self;
721
722 stCoEpoll_t *ev = AllocEpoll();
723 SetEpoll( env,ev );
724 }
以上当stCoRoutineEnv_t
环境不存在时,以当前线程id创建一个环境变量,并创建主协程co_create_env
(我们举的单线程),并coctx_init
,self->cIsMain = 1;
,env->pCallStack[0]
为主协程:
461 struct stCoRoutine_t *co_create_env( stCoRoutineEnv_t * env, const stCoRoutineAttr_t* attr,
462 pfn_co_routine_t pfn,void *arg )
463 {
... more code
495 if( at.share_stack )
496 {
497 stack_mem = co_get_stackmem( at.share_stack);
498 at.stack_size = at.share_stack->stack_size;
499 }
500 else
501 {
502 stack_mem = co_alloc_stackmem(at.stack_size);
503 }
504 lp->stack_mem = stack_mem;
506 lp->ctx.ss_sp = stack_mem->stack_buffer;
507 lp->ctx.ss_size = at.stack_size;
以上根据参数attr
是否开启共享栈,这里分析不考虑共享栈,后面会单独分析下使用共享栈的作用。
269 stStackMem_t* co_alloc_stackmem(unsigned int stack_size)
270 {
271 stStackMem_t* stack_mem = (stStackMem_t*)malloc(sizeof(stStackMem_t));
272 stack_mem->occupy_co= NULL;
273 stack_mem->stack_size = stack_size;
274 stack_mem->stack_buffer = (char*)malloc(stack_size);
275 stack_mem->stack_bp = stack_mem->stack_buffer + stack_size;
276 return stack_mem;
277 }
以上创建协程的栈并设置大小,目前是128kb,然后由于栈是从高地址往低地址增长的,故设置桢指针stack_mem->stack_bp = stack_mem->stack_buffer + stack_size
然后创建task的协程:
stCoRoutine_t *co = co_create_env( co_get_curr_thread_env(), attr, pfn,arg );
并设置这几个值:
490 lp->env = env;
491 lp->pfn = pfn;
492 lp->arg = arg;
即:
490 lp->env = env;
491 lp->pfn = readwrite_routine;
492 lp->arg = task;
以上是create
一个协程的过程,下面是创建完后要resume
,即切入到刚才创建的协程运行:
547 void co_resume( stCoRoutine_t *co )
548 {
549 stCoRoutineEnv_t *env = co->env;
550 stCoRoutine_t *lpCurrRoutine = env->pCallStack[ env->iCallStackSize - 1 ];
551 if( !co->cStart )
552 {
553 coctx_make( &co->ctx,(coctx_pfn_t)CoRoutineFunc,co,0 );
554 co->cStart = 1;
555 }
556 env->pCallStack[ env->iCallStackSize++ ] = co;
557 co_swap( lpCurrRoutine, co );
560 }
这个函数的大概功能是将要运行的协程(切入)和主协程调换(切出),第一次resume
时cStart
为0,只设置一次并设置协程的入口函数CoRoutineFunc
和参数co
,后续的yield
再resume
不会再coctx_make
,然后当前协程压栈进行co_swap
;
115 int coctx_make( coctx_t *ctx,coctx_pfn_t pfn,const void *s,const void *s1 )
116 {
117 char *sp = ctx->ss_sp + ctx->ss_size;
118 sp = (char*) ((unsigned long)sp & -16LL );
119
120 memset(ctx->regs, 0, sizeof(ctx->regs));
121
122 ctx->regs[ kRSP ] = sp - 8;
124 ctx->regs[ kRETAddr] = (char*)pfn;
125
126 ctx->regs[ kRDI ] = (char*)s;
127 ctx->regs[ kRSI ] = (char*)s1;
128 return 0;
129 }
详细分析下这里面的实现,以上设置sp指向高地址并对齐;另外,“%rdi,%rsi,%rdx,%rcx,%r8,%r9
用作函数参数,依次对应第1参数,第2参数...”,所以ctx->regs[ kRDI ]
为协程co参数,ctx->regs[ kRSP ]
设置栈指针减8处的地址,具体用处在后面分析;ctx->regs[ kRETAddr]
为协程的入口函数;
co_swap
主要功能是保存切出协程的栈空间,然后恢复切入协程的栈空间:
599 void co_swap(stCoRoutine_t* curr, stCoRoutine_t* pending_co)
600 {
601 stCoRoutineEnv_t* env = co_get_curr_thread_env();
602
603 //get curr stack sp
604 char c;
605 curr->stack_sp= &c;
606
607 if (!pending_co->cIsShareStack)
608 {
609 env->pending_co = NULL;
610 env->occupy_co = NULL;
611 }
612 else
613 {
614 env->pending_co = pending_co;
615 //get last occupy co on the same stack mem
616 stCoRoutine_t* occupy_co = pending_co->stack_mem->occupy_co;
617 //set pending co to occupy thest stack mem;
618 pending_co->stack_mem->occupy_co = pending_co;
619
620 env->occupy_co = occupy_co;
621 if (occupy_co && occupy_co != pending_co)
622 {
623 save_stack_buffer(occupy_co);
624 }
625 }
626
627 //swap context
628 coctx_swap(&(curr->ctx),&(pending_co->ctx) );
629
630 //stack buffer may be overwrite, so get again;
631 stCoRoutineEnv_t* curr_env = co_get_curr_thread_env();
632 stCoRoutine_t* update_occupy_co = curr_env->occupy_co;
633 stCoRoutine_t* update_pending_co = curr_env->pending_co;
634
635 if (update_occupy_co && update_pending_co && update_occupy_co != update_pending_co)
636 {
637 //resume stack buffer
638 if (update_pending_co->save_buffer && update_pending_co->save_size > 0)
639 {
640 memcpy(update_pending_co->stack_sp, update_pending_co->save_buffer, update_pending_co->save_size);
641 }
642 }
643 }
582 void save_stack_buffer(stCoRoutine_t* occupy_co)
583 {
584 ///copy out
585 stStackMem_t* stack_mem = occupy_co->stack_mem;
586 int len = stack_mem->stack_bp - occupy_co->stack_sp;
587
588 if (occupy_co->save_buffer)
589 {
590 free(occupy_co->save_buffer), occupy_co->save_buffer = NULL;
591 }
592
593 occupy_co->save_buffer = (char*)malloc(len); //malloc buf;
594 occupy_co->save_size = len;
595
596 memcpy(occupy_co->save_buffer, occupy_co->stack_sp, len);
597 }
上面的实现涉及到了共享栈的模式,如下图:
考虑在共享栈模式下,一开始某块栈被协程a占用,然后此时b要切入,那么代码行614〜624执行后:
env->pending_co = b
pending_co->stack_mem->occupy_co为null,故occupy_co=null
pending_co->stack_mem->occupy_co = b
表示此时共享栈被b占用,然后也不需要把共享栈的内容拷贝到occupy_co,因为此时为null;
如果再发生一次切换,即a切入,b切出:
env->pending_co = a
pending_co->stack_mem->occupy_co为b,故occupy_co=b
pending_co->stack_mem->occupy_co = a
表示此时栈被a占用,然后进行了save_stack_buffer
,算出实际使用多少栈空间,然后释放原来的save_buffer
,并分配实际需要大小的栈空间,并从stack_sp
拷贝len
大小的内容进行保存。
使用共享栈,可以不必为每个协程创建固定大小的栈,比如每个协程占用128kb空间,那么32GB的内存,假设都用来创建协程,也只有二十五万个左右,而如果使用共享栈,比如每10个共享一个128kb,那么有二百五十万个这样。
当然使用共享栈也带来了一些性能方面的问题,比如malloc/free/memcpy
等,还有一定的碎片。
代码行630〜643表示coctx_swap
后需要恢复栈空间;
coctx_swap
是一段汇编代码:
56 #elif defined(__x86_64__)
57 leaq 8(%rsp),%rax
58 leaq 112(%rdi),%rsp
59 pushq %rax
60 pushq %rbx
61 pushq %rcx
62 pushq %rdx
63
64 pushq -8(%rax) //ret func addr
65
66 pushq %rsi
67 pushq %rdi
68 pushq %rbp
69 pushq %r8
70 pushq %r9
71 pushq %r12
72 pushq %r13
73 pushq %r14
74 pushq %r15
这部分是切出过程:在coctx_swap(&(curr->ctx),&(pending_co->ctx) )
执行时,参数curr->ctx
地址入rdi
,pending_co->ctx
地址入rsi
,在进入coctx_swap
后,此时rsp
指向返回地址(下一条指令地址);
语句leaq 8(%rsp),%rax
,把rsp + 8
处的地址入rax
,想象ret
指令时,把返回地址pop
到rip
中(rip = rsp, rsp = rsp - 8
),此时rsp
指向的是父函数的栈顶地址(下一条指令地址),和这里一样意思,不需要把返回地址也保存,因为有regs[kRETAddr]
保存;
那么语句leaq 112(%rdi),%rsp
把curr->ctx
加112个字节偏移量处的地址给rsp
,112是sizeof(void*)(=8)*14
,即regs[14]
;
后面的pushq
就是把寄存器中的内容保存到regs
数组中,rax
中的值是要切出的协程调用coctx_swap
时,栈顶除返回地址外栈顶的地址,保存到resg[13]
(pushq
等价于rsp = rsp - 8; mov rax, %rsp
);
其他对应关系如下:
62 // 64 bit
63 low | regs[0]: r15 |
64 | regs[1]: r14 |
65 | regs[2]: r13 |
66 | regs[3]: r12 |
67 | regs[4]: r9 |
68 | regs[5]: r8 |
69 | regs[6]: rbp |
70 | regs[7]: rdi |
71 | regs[8]: rsi |
72 | regs[9]: ret | //ret func addr
73 | regs[10]: rdx |
74 | regs[11]: rcx |
75 | regs[12]: rbx |
76 hig | regs[13]: rsp |
76 movq %rsi, %rsp
77 popq %r15
78 popq %r14
79 popq %r13
80 popq %r12
81 popq %r9
82 popq %r8
83 popq %rbp
84 popq %rdi
85 popq %rsi
86 popq %rax //ret func addr
87 popq %rdx
88 popq %rcx
89 popq %rbx
90 popq %rsp
91 pushq %rax
92
93 xorl %eax, %eax
94 ret
95 #endif
这部分是切入过程:rsi
为第二个参数,这里rsp
指向新的栈空间即regs[0]
,以上实现把regs
中的原有内容popq
到各寄存器中,其中popq %rsp
,把要恢复的栈指针弹到rsp
(即调用coctx_swap
前的rsp
)[这类比于正常的函数调用:压入返回地址;压入上一帧的rbp
,然后使用rbp
指向新的rsp
];
pushq %rax
把返回地址压栈,用xorl 把 %rax 低32位清0以实现地址对齐,然后ret
指令把返回地址弹到rip
中进入跳转;
以上切换部分比较难以理解,关键点在rsp
和rip
,然后借助笔和纸可以画一下栈的交互过程,有助于理解;
由于切入的协程执行readwrite_routine
时,没有连接到达,故把自己push
到vector
中,并切出去:
561 void co_yield_env( stCoRoutineEnv_t *env )
562 {
564 stCoRoutine_t *last = env->pCallStack[ env->iCallStackSize - 2 ];
565 stCoRoutine_t *curr = env->pCallStack[ env->iCallStackSize - 1 ];
566
567 env->iCallStackSize--;
569 co_swap( curr, last);
570 }
当执行完两个task协程时,都因为fd == -1
判断而成立,把自己切出,然后在主协程中创建一个协程用于accept
连接co_accept
,这里略过这几个实现;
大概介绍下co_accept
实现,死循环,如果没有可用的协程则通过epoll睡个timeout时间,超时或唤醒在下面介绍;
然后进行co_eventloop
为了简化分析,假设在理想情况下,如果有连接到来co_accept
协程有事件发生并进行resume
,然后pop
一个task协程,设置co->fd = fd
,并resume
task协程,如果中途遇到阻塞则yield
等。
整体上libco差不多就这样了,上面也说到了,没有协程池的实现,用汇编实现切换有点难以理解,且在切换的时候会可能会很频繁的使用malloc/memcpy
等保存栈内容等,虽然官方“为了减少这种内存拷贝次数,共享栈的内存拷贝只发生在不同协程间的切换。当共享栈的占用者一直没有改变的时候,则不需要拷贝运行栈。”。
在使用协程过程中注意的地方还蛮多的,比如不能使用sleep,全局变量,线程私有变量以及锁等,这些如何改造的可以参考libco的其他实现,这里不过多分析这些细节。
参考资料:
https://segmentfault.com/a/1190000012656741
https://segmentfault.com/p/1210000009166339/read
https://blog.csdn.net/lqt641/article/details/73002566
https://blog.csdn.net/lqt641/article/details/73287231
https://www.zhihu.com/question/52193579?sort=created
https://cloud.tencent.com/developer/article/1049637
https://cloud.tencent.com/developer/article/1005648
https://github.com/Tencent/libco/issues
https://blog.csdn.net/kobejayandy/article/details/46993021
https://segmentfault.com/a/1190000007407881