libco源码学习解读

libco源码学习解读

简介

顺着上一篇文章示例,这里对文章中所涉及的函数及数据类型逐一进行展开分析,以期学习理解libco的实现原理,帮助我们加深对协程的理解,并帮助我们更加有效地使用libco协程库并排查问题。

创建协程co_create

该函数用于创建一个协程对象,其实现源码如下:

int co_create(stCoRoutine_t** ppco, const stCoRoutineAttr_t* attr, pfn_co_routine_t pfn, void* arg)
{
    // 查看当前线程的协程环境变量是否存在,如果不存在则创建初始化线程协程环境
    // 一个线程共用一个协程环境变量
    if (!co_get_curr_thread_env())
    {
        // 创建初始化线程协程环境,并将主线程作为第一个协程,称之为主协程
        co_init_curr_thread_env();
    }
    // 在当前线程的协程环境变量上创建一个新的协程对象
    stCoRoutine_t* co = co_create_env(co_get_curr_thread_env(), attr, pfn, arg);
    // 返回新创建的协程对象
    *ppco = co;
    return 0;
}

其中函数有四个参数,具体作用为:

  • stCoRoutine_t** ppco: 出参,返回创建成功后的协程对象
  • const stCoRoutineAttr_t* attr: 入参,指定创协的协程属性参数
  • pfn_co_routine_t pfn: 入参,指定协程的执行函数
  • void* arg: 入参,指定协程的执行函数的参数

调用函数co_create第一步会检查是否已创建协程环境变量,如果没有则新建初始化,该变量是线程共享的,仅在线程的第一个协程创建的时候一起产生,其协程环境变量的定义如下:

struct stCoRoutineEnv_t
{
    // 线程协程栈,容量有限
    stCoRoutine_t* pCallStack[128];
    // 记录协程栈顶为位置
    int iCallStackSize;
    // 协程epoll支持
    stCoEpoll_t* pEpoll;

    //for copy stack log lastco and nextco
    // 仅在协程共享栈模式的协程切换时候使用
    // 当前协程对象指针
    stCoRoutine_t* pending_co;
    // 协程切换目标协程对象指针
    stCoRoutine_t* occupy_co;
};

stCoRoutineEnv_t的定义可以看出,线程的协程是用栈的方式管理的,该栈用户保存协程的链式调用栈信息,变量iCallStackSize指定当前调用栈的栈顶位置。

新建初始化协程环境时,除了创建了协程环境变量,还添加了首个协程。为了便于理解,这里先做个约定,在libco里面,一个协程仅会在一个线程上运行,一个线程允许有多个协程,我们将主线程代码也称之为一个协程并命名为主协程,因此一个线程至少有一个主协程,然后还有我们的业务工作协程。函数co_init_curr_thread_env源码如下:

void co_init_curr_thread_env()
{
    // 创建协程环境变量并赋值给全局变量
    gCoEnvPerThread = (stCoRoutineEnv_t*)calloc(1, sizeof(stCoRoutineEnv_t));
    stCoRoutineEnv_t* env = gCoEnvPerThread;

    // 将当前执行主代码设置为第一个协程,主协程
    // 主协程无协程函数体,是一个特殊的协程对象
    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;

    // 初始化协程上下文,实际执行的为对象memset为0
    coctx_init(&self->ctx);

    // 设置主协程为协程栈的第一个协程
    env->pCallStack[env->iCallStackSize++] = self;

    // 初始化并设置epoll对象
    stCoEpoll_t* ev = AllocEpoll();
    SetEpoll(env, ev);
}

在调用函数co_init_curr_thread_env新建初始化协程环境变量后,会将当前执行的主线程(可简单认为是main函数)初始化为主协程,并将之入协程调用栈,成为第一个协程栈对象。最后,初始化本线程协程环境的epoll对象,完成协程环境变量的初始化。可以发现,协程环境变量的初始化一个线程只执行一次,后续不再重复执行,后面创建协程时通过co_get_curr_thread_env函数获取线程全局共享的协程变量环境直接使用。

接着函数co_create的源码继续看,完成协程变量环境初始化后,则调用协程真正的协程创建函数co_create_env,传入外部入参及环境协程环境变量,并返回创建成功的协程对象指针,源码信息如下:

struct stCoRoutine_t* co_create_env(stCoRoutineEnv_t* env, const stCoRoutineAttr_t* attr, pfn_co_routine_t pfn, void* arg)
{
    // 设置协程属性值,主要为栈大小
    stCoRoutineAttr_t at;
    if (attr)
    {
        memcpy(&at, attr, sizeof(at));
    }
    if (at.stack_size <= 0)
    {
        at.stack_size = 128 * 1024;
    }
    else if (at.stack_size > 1024 * 1024 * 8)
    {
        at.stack_size = 1024 * 1024 * 8;
    }
    if (at.stack_size & 0xFFF)
    {
        at.stack_size &= ~0xFFF;
        at.stack_size += 0x1000;
    }

    // 申请内存创建协程对象(类似于进程控制块,此处可称之为协程控制块,CCB)
    stCoRoutine_t* lp = (stCoRoutine_t*)malloc(sizeof(stCoRoutine_t));
    memset(lp, 0, (long)(sizeof(stCoRoutine_t)));
    // 设定协程环境变量,协程执行函数及其参数
    lp->env = env;
    lp->pfn = pfn;
    lp->arg = arg;

    // 检查协程是否使用共享协程栈
    // 共享协程栈:取共享协程栈
    // 非共享协程栈:为当前协程申请分配内存
    stStackMem_t* stack_mem = NULL;
    if (at.share_stack)
    {
        stack_mem = co_get_stackmem(at.share_stack);
        at.stack_size = at.share_stack->stack_size;
    }
    else
    {
        stack_mem = co_alloc_stackmem(at.stack_size);
    }
    // 设置协程运行栈空间
    lp->stack_mem = stack_mem;
    // 设置协程运行栈的栈顶指针(相对于bp是低地址)
    lp->ctx.ss_sp = stack_mem->stack_buffer;
    // 设置协程运行栈的大小
    lp->ctx.ss_size = at.stack_size;

    // 设置协程栈其他标志
    // cStart=0,未启动;
    // cIsMain=0,非主协程
    lp->cStart = 0;
    lp->cEnd = 0;
    lp->cIsMain = 0;
    lp->cEnableSysHook = 0;
    lp->cIsShareStack = at.share_stack != NULL;

    // 共享运行栈时,协程切换需要保存的栈信息
    lp->save_size = 0;
    lp->save_buffer = NULL;

    return lp;
}

在函数co_create_env主要完成的操作为:

  • 设置协程属性值,主要为栈大小
  • 申请内存创建协程对象(CCB),并设置环境变量和外部传入的协程函数及其参数
  • 设定协程的运行栈信息
  • 设定协程的默认标志位信息

协程对象类型stCoRoutine_t,我们可参照线程进程的概念,称之为协程控制块,即CCB,其定义如下:

struct stCoRoutine_t
{
    // 协程环境变量,同属于一个线程所有协程共用的执行环境
    stCoRoutineEnv_t* env;
    // 协程执行函数
    pfn_co_routine_t pfn;
    // 协程执行函数参数
    void* arg;
    // 协程上下文环境,主要包括寄存器信息
    coctx_t ctx;

    // 协程标志位信息
    char cStart; // 是否已启动
    char cEnd; // 是否已结束
    char cIsMain; // 是否为主协程
    char cEnableSysHook; // 是否启用了HOOK项
    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;

    // 用于setspecific和getspecific的键值对存储
    stCoSpec_t aSpec[1024];
};

此时,一个协程所需要的协程控制块、运行栈、执行上下文、协程执行函数都已经具备,协程创建完毕。

协调条件变量及定时器

创建协程时,我们指定了执行协程函数的参数类型为stPara_t,其中包含一个协程条件变量stCoCond_t,生产者消费者之间的通讯使用的是条件变量,但是此处的条件变量非系统提供的原始类型,而是libco包装后的数据类型stCoCond_t,其定义如下:

struct stCoCondItem_t 
{
    stCoCondItem_t *pPrev;
    stCoCondItem_t *pNext;
    stCoCond_t *pLink;

    stTimeoutItem_t timeout;
};
struct stCoCond_t
{
    stCoCondItem_t *head;
    stCoCondItem_t *tail;
};

stCoCond_t仅有两个成员变量headtail,其类型均为stCoCondItem_t,而stCoCondItem_t为带有超时的双向链表元素类型,因此stCoCond_t为一个双向链表(list)的数据类型。

函数co_cond_alloc仅是申请分配了类型为stCoCond_t的对象,后续使用是通过定时器来管理的。

在分析stCoRoutineEnv_t 结构中,有一个stCoEpoll_t类型的pEpoll指针成员,作为stCoRoutineEnv_t的成员,这个结构也是一个全局性的资源,被同一个线程上所有协程共享,是一个跟epoll的事件循环相关的变量,其定义如下:

struct stCoEpoll_t
{
    // epoll 实例的⽂件描述符
    int iEpollFd;
    // 值为 10240 的整型常量。作为epoll_wait()系统调用的第三个参数
    // 即⼀次 epoll_wait 最多返回的就绪事件个数
    static const int _EPOLL_SIZE = 1024 * 10;
    // 类型为stTimeout_t的结构体指针
    // 该结构实际上是⼀个时间轮(Timingwheel)定时器
    struct stTimeout_t* pTimeout;
    // 指向 stTimeoutItemLink_t 类型的结构体指针
    // 该指针实际上是⼀个链表头,链表用于临时存放超时事件的 item
    struct stTimeoutItemLink_t* pstTimeoutList;
    // 指向 stTimeoutItemLink_t 类型的结构体指针,也是指向⼀个链表
    // 该链表用于存放epoll_wait得到的就绪事件和定时器超时事件
    struct stTimeoutItemLink_t* pstActiveList;
    // 对 epoll_wait()第⼆个参数的封装,即⼀次 epoll_wait 得到的结果集
    co_epoll_res* result;
};

我们知道,定时器是事件驱动模型的网络框架一个必不可少的功能。网络I/O的超时,定时任务,包括定时等待(poll或timedwait)都依赖于此。一般而言,使用定时功能时,我们首先向定时器中注册一个定时事件TimerEvent,在注册定时事件时需要指定这个事件在未来的触发时间。在到了触发时间点后,我们会收到定时器的通知。

网络框架里的定时器可以看做由两部分组成,第一部分是保存已注册timerevents的数据结构。第二部分则是定时通知机制,保存已注册的timerevents,一般选用红黑树,比如nginx;另外一种常见的数据结构便是时间轮,libco就使用了这种结构。当然你也可以直接用链表来实现,只是时间复杂度比较高,在定时任务很多时会很容易成为框架的性能瓶颈。

定时器的第二部分,高精度的定时(精确到微秒级)通知机制,一般使用getitimer/setitimer这类接口,需要处理信号,是个比较麻烦的事。不过对一般的应16用而言,精确到毫秒就够了。精度放宽到毫秒级时,可以顺便用epoll/kqueue这样的系统调用来完成定时通知;这样一来,网络I/O事件通知与定时事件通知的逻辑就能统一起来了。笔者之前实现过的一个基于libcurl的异步HTTPclient,其中的定时器功能就是用epoll配合红黑树实现的。libco内部也直接使用了epoll来进行定时,不同的只是保存timerevents的用的是时间轮而已。

使用epoll加时间轮的实现定时器的算法如下:

  • Step1[epoll_wait]调用epoll_wait()等待I/O就绪事件,最⼤等待时长设置为1毫秒(即epoll_wait()的第4个参数)。
  • Step2[处理I/O就绪事件]循环处理epoll_wait()得到的I/O就绪⽂件描述符。
  • Step3[从时间轮取超时事件]从时间轮取超时事件,放到timeout队列。
  • Step4[处理超时事件]如果Step3取到的超时事件不为空,那么循环处理timeout队列中的定时任务。否则跳转到Step1继续事件循环。
  • Step5[继续循环]跳转到Step1,继续事件循环。

示例中,有两处用到条件变量,分别为co_cond_signalco_cond_timedwait,其功能类似于pthread_cond_signalpthread_cond_timewait,其中co_cond_signal的源码实现如下:

int co_cond_signal(stCoCond_t* si)
{
    // POP并取得头部对象
    stCoCondItem_t* sp = co_cond_pop(si);
    if (!sp)
    {
        return 0;
    }
    // 移除头部的超时对象
    RemoveFromLink<stTimeoutItem_t, stTimeoutItemLink_t>(&sp->timeout);
    
    // 将该对象处理到就绪(活动)链表中
    AddTail(co_get_curr_thread_env()->pEpoll->pstActiveList, &sp->timeout);

    return 0;
}

恢复或启动协程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;
    // 切换协程运行上下文,从lpCurrRoutine切换到co
    co_swap(lpCurrRoutine, co);
}

当执行co_swap函数后,并不会立即返回,因为该函数已经将程序的执行切换到别的协程,因此需要等别的协程执行完或者主动让出执行权限后才会返回。

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);
        }
    }
    
    // 协程上下文切换,从curr切换到pending_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);
        }
    }
}

libco中,协程与协程之间存在这严格的调用与被调用的关系,是一种非对称的协程。非对称协程(asymmetric coroutines)是跟一个特定的调用者绑定的,协程让出 CPU 时,只能让回给原调用者,因此协程的切换操作,一般而言只有一个yield操作,用于将程序控制流转移给另外的协程,即调用者协程。

结束语

这篇文章主要是针对上一篇文章的示例中使用到的内容做源码展开解读,因此文章内容看起来比较乱,是对示例的进一步分析,不适合学习libco的整体情况,如需把握libco的设计理论及整体技术方案,可参考此处

你可能感兴趣的:(c++,Linux,深入理解计算机系统)