从 Protothreads 和 libco 看 C/C++ 实现的协程库
同步Synchronous,异步(Asynchronous),协程(coroutine)
同步的好处是逻辑流就是代码的控制流,易于编写。但是如果碰到阻塞请求,就会卡住,因此CPU利用率不高。当然操作系统可以进行进程/线程调度,但是又有一些上下文切换的开销。
异步的好处是当线程可以不用一直阻塞在IO请求上,返回的逻辑可以写在回调里。但是这样有两个问题,一个是逻辑流不等于控制流,coder需要去适应异步的思想;二是回调时也需要保证维持一些当初请求的状态,这个时候比较繁琐,并且容易出错。
协程的主要目的是,要让所有CPU都能跑满,这样可以最大化利用计算资源。并且协程编写跟同步很相似,业务逻辑不会改变。协程相比于传统的进程/线程,有一个优势是调度在用户态,因此开销小(不用用户态->内核态切换),同时协程的并发量主要受制于内存大小。
Protothreads
Protothreads 是一个短小精悍的协程库,它对于每个协程都只需要2字节来保存状态。主页:http://dunkels.com/adam/pt/index.html (PS: 这个作者非常有名)
协程最重要需要解决的问题:
- 如何保存当前处理的状态(寄存器,局部变量等。。)
- 如何保证下次可以在当前位置继续执行。
Protothreads 解决了第二个问题,它对待第一个问题的方式是不允许用户在函数里定义局部变量 : ),换句话说你定义的局部变量这些都没用,所以是无状态的。当然你可以用全局变量去维护一些状态。
解决第二个问题的方式是使用 switch-case 语句跳转,类似于 goto。那么怎么区分跳转位置呢,采用 LABEL_行号
的方式解决(行号就是上面说的2字节),这样你第一次进入函数的时候是正常执行的,但是当你后面进入函数时,通过 switch 可以直接跳转到指定 label 继续执行。
pt 的几个宏定义:
// lc-switch.h
typedef unsigned short lc_t;
#define LC_INIT(s) s = 0;
#define LC_RESUME(s) switch(s) { case 0:
#define LC_SET(s) s = __LINE__; case __LINE__:
#define LC_END(s) }
/************************************daghlny 专有分割线***************************************/
// pt.h
#define PT_INIT(pt) LC_INIT((pt)->lc)\
#define PT_BEGIN(pt) { char PT_YIELD_FLAG = 1; LC_RESUME((pt)->lc)
#define PT_END(pt) LC_END((pt)->lc); PT_YIELD_FLAG = 0; \
PT_INIT(pt); return PT_ENDED; }
#define PT_WAIT_UNTIL(pt, condition) \
do { \
LC_SET((pt)->lc); \
if(!(condition)) { \
return PT_WAITING; \
} \
} while(0)
#define PT_YIELD(pt) \
do { \
PT_YIELD_FLAG = 0; \
LC_SET((pt)->lc); \
if(PT_YIELD_FLAG == 0) { \
return PT_YIELDED; \
} \
} while(0)
上面这个是一种 LABEL 策略 (定义在 lc-switch.h),另一种是使用 C语言的 &&LABEL 语法糖(定义在lc-addrlabels.h),然后在 lc.h 文件中进行选择使用。如果需要看到 pt 的具体执行,可以用 gcc -E
来打印出来宏展开之后的代码。
libco
libco 是微信给出的一个协程库,代码量也不大,并且是经过微信的业务考验的,当然文档很差(基本没有注释),并且更新很慢(commit已经很久远了),但是依然是一个比较健全的协程库。主页:https://github.com/Tencent/libco
libco 采用的是 hook 所有常用的 Unix 系统调用,包括 read, write, send, recv
等。然后在每次进行这些调用时,会先观察是否启用了 hook,然后观察是否 fd 设置了 O_NONBLOCK
,然后再观察是否设置了 timeout == 0
,最后才会采用自己定义的 poll 函数来注册 fd,然后将控制权移交到其他协程中去。
下面是一个 hook read 的代码:
ssize_t read( int fd, void *buf, size_t nbyte )
{
HOOK_SYS_FUNC( read );
if( !co_is_enable_sys_hook() )
{
return g_sys_read_func( fd,buf,nbyte );
}
rpchook_t *lp = get_by_fd( fd );
if( !lp || ( O_NONBLOCK & lp->user_flag ) )
{
ssize_t ret = g_sys_read_func( fd,buf,nbyte );
return ret;
}
int timeout = ( lp->read_timeout.tv_sec * 1000 )
+ ( lp->read_timeout.tv_usec / 1000 );
struct pollfd pf = { 0 };
pf.fd = fd;
pf.events = ( POLLIN | POLLERR | POLLHUP );
int pollret = poll( &pf,1,timeout );
ssize_t readret = g_sys_read_func( fd,(char*)buf ,nbyte );
if( readret < 0 )
{
co_log_err("CO_ERR: read fd %d ret %ld errno %d poll ret %d timeout %d",
fd,readret,errno,pollret,timeout);
}
return readret;
}
但是,libco 如何保存当前线程的状态呢,具体的逻辑在 co_routine.cpp/co_swap()
函数里,函数定义如下:
void co_swap(stCoRoutine_t* curr, stCoRoutine_t* pending_co)
{
stCoRoutineEnv_t* env = co_get_curr_thread_env();
//get curr stack sp
char c;
curr->stack_sp= &c;
if (!pending_co->cIsShareStack)
{
env->pending_co = NULL;
env->occupy_co = NULL;
}
else
{
env->pending_co = pending_co;
//get last occupy co on the same stack mem
stCoRoutine_t* occupy_co = pending_co->stack_mem->occupy_co;
//set pending co to occupy thest stack mem;
pending_co->stack_mem->occupy_co = pending_co;
env->occupy_co = occupy_co;
if (occupy_co && occupy_co != pending_co)
{
save_stack_buffer(occupy_co);
}
}
//swap context
coctx_swap(&(curr->ctx),&(pending_co->ctx) );
//stack buffer may be overwrite, so get again;
stCoRoutineEnv_t* curr_env = co_get_curr_thread_env();
stCoRoutine_t* update_occupy_co = curr_env->occupy_co;
stCoRoutine_t* update_pending_co = curr_env->pending_co;
if (update_occupy_co && update_pending_co && update_occupy_co != update_pending_co)
{
//resume stack buffer
if (update_pending_co->save_buffer && update_pending_co->save_size > 0)
{
memcpy(update_pending_co->stack_sp, update_pending_co->save_buffer, update_pending_co->save_size);
}
}
}
假设现在要从协程 A(就是参数 curr) 切换到协程 B(就是参数 pending_co),其中 save_stack_buffer()
函数是用来保存协程A栈的存储内容的,而 coctx_swap()
是用来将 A 的寄存器内容保存到到自己的 stCoRoutine_t
结构中,然后将 B 的寄存器内容恢复回来。首先,stCoRoutine_t
的 ctx
字段是 coctx_t
结构,定义如下:
struct coctx_t
{
#if defined(__i386__)
void *regs[ 8 ];
#else
void *regs[ 14 ];
#endif
size_t ss_size;
char *ss_sp;
};
coctx_swap()
这个函数是用汇编写的,代码是:
#elif defined(__x86_64__)
leaq 8(%rsp),%rax
leaq 112(%rdi),%rsp
pushq %rax
pushq %rbx
pushq %rcx
pushq %rdx
pushq -8(%rax) //ret func addr
pushq %rsi
pushq %rdi
pushq %rbp
pushq %r8
pushq %r9
pushq %r12
pushq %r13
pushq %r14
pushq %r15
movq %rsi, %rsp
popq %r15
popq %r14
popq %r13
popq %r12
popq %r9
popq %r8
popq %rbp
popq %rdi
popq %rsi
popq %rax //ret func addr
popq %rdx
popq %rcx
popq %rbx
popq %rsp
pushq %rax
xorl %eax, %eax
ret
#endif
方便起见只我只截取了 x86_64 的。函数定义中,第一句leaq 8(%rsp),%rax
,实际上是将除了返回之外的协程A栈顶放到 %ras 寄存器中,leaq 112(%rdi),%rsp
是将 %rsp 指向上面 coctx_t
结构的 regs
数组的末尾(也就是地址最大的一端)。然后通过后续的 pushq,就可以把其他的寄存器放到 regs 中进行保存。 然后将 %rsp 指向 %rsi (根据之前的某篇博文,这个参数在64位下应该是第二个传入参数),然后使用一系列 popq 指令再将协程B之前保存的寄存器状态依次从 regs 放入每个寄存器中。
然后用 pushq %rax
来将返回地址重新压栈,最后使用 ret
来保证 %rip 寄存器指向指定的指令位置,然后就恢复了 协程B 的上下文状态,这样就可以继续执行了。