libco协程库原理解析与应用

       最近在准备一个libco协程库原理简析与应用的分享,顺便就整理下写个博客,一方面加深下自己对协程库的理解,另一方面也希望能对想了解协程的学者有所帮助。废话不多说了,言归正传吧。

        想去剖析libco协程库的实现原理,首先我们要了解下什么是协程。维基百科上给协程的定义是协程(coroutine)又称微线程,是一个无优先级的子程序(函数)调度组件,允许子程序在特定的地方挂起和恢复。我们可通过一个例子来理解下这句话的意思。函数调用大家都很熟悉吧,下面的这个是一个函数调用的例子。

    libco协程库原理解析与应用_第1张图片

    我们可以很容易的看出函数执行的结果是1 2 3 x y z。而协程的定义说是函数调度组件,但不同的是允许子程序在特定的地方挂起和恢复。也就是调用A函数时可以执行到一半然后挂起去执行B函数,之后可以从A函数挂起的地方继续执行,例如下面的例子:

    libco协程库原理解析与应用_第2张图片

    这个程序执行的结果是1 2 x 3 y z。从这个例子中我们可以对协程的定义有个很清晰的认识了。

     了解了协程的定义,那么我们为什么需要协程呢,协程有什么的作用呢,接下来我们来看下协程的由来

       起初人们喜欢同步编程,然后发现有一堆线程因为I/O卡在那里,并发上不去,资源严重浪费。 网络服务器模型一般为while(1){accept(); read(); do(); send()}。

        然后出了异步(select,epoll,kqueue,etc),将I/O操作交给内核线程,自己注册一个回调函数处理最终结果。但由于这种方式回调函数的应用,项目大了之后回调函数的嵌套会层出不穷, 代码结构变得不清晰,例如下面这个例子:

    libco协程库原理解析与应用_第3张图片

        于是发明了协程,写同步的代码,享受着异步带来的性能优势。

          就实际使用理解来讲,协程允许我们写同步代码的逻辑,却做着异步的事,避免了回调嵌套,使得代码逻辑清晰。协程是追求极限性能和优美的代码结构的产物。例如下面这个例子:

      libco协程库原理解析与应用_第4张图片

        那么接下来我们就来具体分析下libco协程库的实现吧

        libco是腾讯开源的一个协程库,主要应用于微信后台RPC框架。早期微信后台因为业务需求复杂多变、产品要求快速迭代等需求,大部分模块都采用了半同步半异步模型。接入层为异步模型,业务逻辑层则是同步的多进程或多线程模型,业务逻辑的并发能力只有几十到几百。随着微信业务的增长,系统规模变得越来越庞大,每个模块很容易受到后端服务/网络抖动的影响。于是微信就自己开发了一个libco协程库来处理同步的rpc调用。

        首先我们看下libco的框架:

libco协程库原理解析与应用_第5张图片

        我们看到libco框架分为三层,底层仍然用现在使用比较广泛的i/o多路复用模型来实现异步i/o。中间层是对系统函数的hook层,主要是将阻塞的系统i/o调用(如read,write)改为异步的调用。最上层是用户接口层,也是我们使用libco库最直接接触的一层。该层实现了协程原语(协程创建,执行,调度,等),且实现了一套协程间通信的信号量。libco实现协程的核心有两点, 一点是对协程上下文的切换,另一点就是对同步接口的异步化。接下来我们主要从这两点来解析libco的实现。

        首先我们看下协程上下文切换的实现,从下面这个例子我们可以看出co_create,co_resume,co_yield_ct完成了协程的创建,启动和挂起操作。那我们看下co_resume和co_yield内部都实现了什么.

        libco协程库原理解析与应用_第6张图片


            libco协程库原理解析与应用_第7张图片

        我们看到这两个函数都调用了coctx_swap()这个函数,这个函数就是libco协程上下文切换的实现。我们看下这个函数的原型:

        extern void coctx_swap( coctx_t *,coctx_t* ) asm("coctx_swap");、

        可以看出coctx_swap实际上是调用的是汇编函数,该函数有两个类型为coctx_t的参数,分别代表挂起和恢复的协程,我们看下coctx_t这个结构体的实现:

           struct coctx_t{
              void *regs[14];
               size_t ss_size;
              char *ss_sp;

            };

        我们知道协程是用户级线程,其共享同一套寄存器,所以当要挂起该协程时要把该协程的寄存器信息保存起来,regs就是用来保存寄存器信息的数组。而ss_sp则是指向协程的栈帧信息,libco为每个协程在堆上分配了128k的空间作为该协程的栈帧。那么进行协程的切换其实是做了三件事,一是保存挂起协程的寄存器信息,二是恢复启动协程的寄存器信息,三是跳转到启动协程的返回地址继续执行。

       我们来看下coctx_swap的汇编实现:

        libco协程库原理解析与应用_第8张图片

        在分析这个汇编函数前,我们先来了解下寄存器的一些知识。%rsp寄存器是栈顶寄存器,通过移动它可以指定栈信息,对某些栈空间进行操作。%rax是返回地址寄存器,如在函数调用时保存函数返回后继续要执行的地址。%rdi,%rsi则分别存储函数调用时的参数1和参数2,例如在coctx_swap这个函数中,%rdi,%rsi则分别存储挂起和恢复的协程上下文信息。其他则是一些参数和数据寄存器。知道上面这几个寄存器的作用,就好分析coctx_swap这个汇编函数了。那么接下来我们具体分析下。

        leaq 8(%rsp), %rax是将rsp的上一个地址保存到%rax上,其实也就是把当前的%rsp地址先给保存到%rax上,因为后面会通过移动%rsp来把寄存器信息保存到regs数组中,这里要说明下%rsp是栈顶寄存器,通过移动它可以指定栈帧地址。leaq 112(%rdi),%rsp试讲%rsp指向了挂起协程的regs[13]处然后再该地址处压入个寄存器信息,即将寄存器信息存入regs[13]中。这就是第一部分,存储挂起协程的寄存器信息。

        movq %rsi, %rsp则是将%rsp执行恢复协程的regs地址处,然后将regs数组中存储的信息恢复到相应寄存器中。pushq %rax将返回地址入栈。这部分就是恢复要启动的携程的寄存器信息。

           xorl %eax, %eax ret是子程序返回指令,弹出栈顶数据开始执行,而此时栈顶是上一步中push的返回地址,所以挑战到了要启动的协程上下文开始执行。整个上下文切换过程就是这么处理的。

            接下来我们来分析下libco系统调用hook层的实现。这层主要是对rpc同步系统调用在不改变编码风格的前提下异步化。该层的源代码实现在co_hook_sys_call.cpp中,通过dlsym函数对阻塞的系统调用加个钩子,改造成异步的调用。

            对rpc同步系统调用异步化的大概步骤如下:

   1.通过fcntl将阻塞的文件描述符设置为非阻塞

    2.通过poll函数向内核注册I/O和超时事件并挂起协程,待I/O或超时事件触发则恢复协程继续进行异步I/O操作(因为此时文件描述符已设为非阻塞),这步即是将同步+超时的rpc调用改为异步+超时的rpc调用。

    3.返回结果

    接下来我们具体分析下hook的源码:

     在co_hook_sys_call.cpp文件中,为每个文件描述符分配了一个rpchook_t结构的实例:

struct rpchook_t
{
int user_flag;
struct sockaddr_in dest; //maybe sockaddr_un;
int domain; //AF_LOCAL , AF_INET


struct timeval read_timeout;
struct timeval write_timeout;
};

static rpchook_t *g_rpchook_socket_fd[ 102400 ] = { 0 };

在rpchook_t结构体找那个user_flag代表用户设置的文件描述符是阻塞的还是非阻塞的标记,read_timeout, write_timeout分别表示该文件描述符的读写超时时间默认设置是1秒,也可通过setsockopts设置读写超时时间。我们接下来通过read函数来详细讲述下阻塞的接口如何异步化的。read的源代码如下:

ssize_t read( int fd, void *buf, size_t nbyte )

{

        //未使用hook函数或是非阻塞调用或未分配rpchost_t结构的文件描述符都调用系统调用

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;

}


        //同步的rpc调用向内核注册该fd的读事件和超时事件,若事件为发生挂起该协程处理其他协程,待事件到达后恢复协程

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 );

        //fd可读或超时事件发生协程恢复进行读操作,此时虽然调用的是系统为hook的调用但fd已设置为非阻塞模式,所以这是

        //一个异步读操作

ssize_t readret = g_sys_read_func( fd,(char*)buf ,nbyte );

if( readret < 0 )

{

                //fd不可读,相当于rpc同步时的等待超时,只是在这里是异步超时因为线程并没有挂起而是协程挂起去处理其他协程

co_log_err("CO_ERR: read fd %d ret %ld errno %d poll ret %d timeout %d",
fd,readret,errno,pollret,timeout);
}

return readret;

}

        其他的阻塞操作如connect, write等大概也是通过poll向内核注册i/o和超时事件并挂起协程,等待事件的发生来完成异步化的,在这里就不一一介绍了。

        libco的两个核心内容,即上下文切换和hook层同步调用异步化基本上也就讲完了,详细的代码实现还要各位再好好研读下,这里就不多介绍源码了。

你可能感兴趣的:(服务端研发)