nifengz

腾讯libco协程切换分析

2018-04-12

腾讯libco协程切换分析

去年通读了腾讯的libco,一个高性能的协程库。有许多值得学习的地方:如基于时间轮盘和双向链表实现的定时器,和nginx 相似的多进程IO模型,基于dlsym 的system call hook,多协程使用一个运行时栈来使空间占用与时间消耗达到平衡等。这篇文章不会对这些做介绍,网上有不少libco分析的文章可供参考。在这里我只想对协程切换时的关键代码做出分析,由于现在PC早已是64位,所以我们只关注64位的代码,忽略32位的部分。

协程的实现其实就是运行时栈的保存和恢复的过程,我们称之为上下文切换(context swap)。我们知道在linux 实现上下文切换可以使用 ucontext云风 有一版使用ucontext简单的协程实现 c_coroutine 。出于性能上的考虑,libco使用汇编代码来进行context swap。

libco的协程API比较简洁,co_create用于创建协程,co_resume用于唤醒或者运行协程,co_yield用于挂起协程。在介绍协程切换之前,我们需要知道协程切换的时,context包含那些内容? libco使用如下结构来保存协程运行时的context:

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

ss_size是协程的栈空间大小,ss_sp 是协程使用的栈的起始地址, regs[14] 用来存放寄存器的值,它的结构如下:

//-------------
//low | regs[0]: r15 |
//    | regs[1]: r14 |
//    | regs[2]: r13 |
//    | regs[3]: r12 |
//    | regs[4]: r9  |
//    | regs[5]: r8  | 
//    | regs[6]: rbp |
//    | regs[7]: rdi |
//    | regs[8]: rsi |
//    | regs[9]: ret |  //ret func addr
//    | regs[10]: rdx |
//    | regs[11]: rcx | 
//    | regs[12]: rbx |
//hig | regs[13]: rsp |
//下面的枚举类型用来方便使用index来操作数组
enum
{
    kRDI = 7,
    kRSI = 8,
    kRETAddr = 9,
    kRSP = 13,
};

你可能会有疑问,为什么不是所有的寄存器都保存呢? 根据System V ABI调用约定我们知道r10和r11寄存器是函数调用放保存的,协程的切换是在函数中进行的,所以无需保存r10和r11。如果你想详细了解X86-64的函数调用约定可以参考 这篇博客

每一个协程对象中都会有一个coctx_t结构用来保存切换时的context,当一个协程初次运行(第一次调用co_resume启动一个协程)的时候会初始化这个结构,初始化的代码如下:

int coctx_make( coctx_t *ctx,coctx_pfn_t pfn,const void *s,const void *s1 )
{
    //栈从高地址向低地址生长,取得stack pointer
    char *sp = ctx->ss_sp + ctx->ss_size;
    //-16LL的16进制表示是0xfffffffffffffff0
    //& -16LL 相当于后四位清0,也就是最后16bit清零,完成16字节对齐
    sp = (char*) ((unsigned long)sp & -16LL  );

    memset(ctx->regs, 0, sizeof(ctx->regs));
    //kRSP=13 这里为什么要-8保存? 在协程唤醒时就可以找到答案,现在先放一放
    ctx->regs[ kRSP ] = sp - 8;
    //kRETAddr=9 用于存放要执行的函数的地址(这个地址将来会赋值给rip 以达到跳转到这个函数执行的目的)
    ctx->regs[ kRETAddr] = (char*)pfn;
    //根据调用约定,将两个传参依次放入 rsi和 rdi,以便启动协程的时候传给函数pfn
    ctx->regs[ kRDI ] = (char*)s;
    ctx->regs[ kRSI ] = (char*)s1;
    return 0;
}

此函数有四个参数,第一个参数是新的协程使用的context对象,第二个参数是协程对应的执行函数,后面两个是传给执行函数的参数。我们可以看到函数先初始化栈指针,这里做了16 Byte 对齐,相关内容可以Google x64 stack Alignment, 然后在函数执行前进行了初始化操作,如预先保存rsp,并把参数保存到相关的结构里,这部分需要对照下面的协程切换部分来理解。

co_resumeco_yield函数中需要进行协程的切换。由内部函数co_swap来完成,co_swap的结构简化如下:

void co_swap(stCoRoutine_t* curr, stCoRoutine_t* pending_co)
{
    //获取当前正在运行的协程
    stCoRoutineEnv_t* env = co_get_curr_thread_env();
    ...
    //保存当前正在运行的协程的数据

    //这个汇编函数就是切换切成的关键代码
    coctx_swap(&(curr->ctx),&(pending_co->ctx) );

    ...
    //挂起的协程恢复后会执行到这里
    //协程恢复后,需要拷贝上次挂起时保存的栈数据到当前的运行栈中,然后继续运行

}

下面我们看coctx_swap:

.globl coctx_swap
.type  coctx_swap, @function
coctx_swap:
    leaq 8(%rsp),%rax  # rax=(*rsp) + 8;
                       # 此时栈顶元素是当前的%rip(即当前协程挂起后被再次唤醒时,需要执行的下一条指令
                       # 的地址),后面会把栈顶的这个地址保存到curr->ctx->regs[9]中,所以保存rsp的
                       # 时候就跳过这8个字节了
    leaq 112(%rdi),%rsp#rsp=(*rdi) + (8*14);
                       # %rdi存放的是函数第一个参数的地址,即curr->ctx的地址
                       # 然后加上需要保存的14个寄存器的长度,使rsp指向curr->ctx->regs[13]
    pushq %rax         # curr->ctx->regs[13] = rax;
                       # 保存rsp,看第一行代码的注释
    pushq %rbx         # curr->ctx->regs[12] = rbx;
    pushq %rcx         # curr->ctx->regs[11] = rcx;
    pushq %rdx         # curr->ctx->regs[10] = rcx;
    pushq -8(%rax)     # curr->ctx->regs[9] = (*rax) - 8;
                       # 把协程挂起后被再次唤醒时,需要执行的下一条指令的地址保存起来
    pushq %rsi         # curr->ctx->regs[8] = rsi;
    pushq %rdi         # curr->ctx->regs[7] = rdi;
    pushq %rbp         # curr->ctx->regs[6] = rbp;
    pushq %r8          # curr->ctx->regs[5] = r8;
    pushq %r9          # curr->ctx->regs[4] = r9;
    pushq %r12         # curr->ctx->regs[3] = r12;
    pushq %r13         # curr->ctx->regs[2] = r13;
    pushq %r14         # curr->ctx->regs[1] = r14;
    pushq %r15         # curr->ctx->regs[0] = r15;

    movq %rsi, %rsp    # rsp = rsi; 
                       # rsi中存放的是函数的第二个参数的地址,即使rsp指向pending_co->ctx->regs[0]
    popq %r15          # r15 = pending_co->ctx->regs[0];
    popq %r14          # r14 = pending_co->ctx->regs[1];
    popq %r13          # r13 = pending_co->ctx->regs[2];
    popq %r12          # r12 = pending_co->ctx->regs[3];
    popq %r9           # r9 = pending_co->ctx->regs[4];
    popq %r8           # r8 = pending_co->ctx->regs[5];
    popq %rbp          # rbp = pending_co->ctx->regs[6];
    popq %rdi          # rdi = pending_co->ctx->regs[7];
    popq %rsi          # rsi = pending_co->ctx->regs[8];
    popq %rax          # rax = pending_co->ctx->regs[9];
                       # 对照前面,ctx->regs[9]中存放的是协程被唤醒后需要执行的下一条指令的地址
    popq %rdx          # rdx = pending_co->ctx->regs[10]; 
    popq %rcx          # rcx = pending_co->ctx->regs[11];
    popq %rbx          # rbx = pending_co->ctx->regs[12];
    popq %rsp          # rsp = pending_co->ctx->regs[13]; rsp += 8;
                       # 这句代码是理解整个过程的关键。和coctx_make函数中保存rsp时减8再保存相对应。
    pushq %rax         # rsp -= 8;*rsp = rax;
                       # 此时栈顶元素就是协程被唤醒后需要执行的下一条指令的地址了

    xorl %eax, %eax    # eax = 0;
                       # 使eax清零,eax中的内容作为函数的返回值 
    ret                # 相当于popq %rip 这样就可以唤醒上次挂起的协程,接着运行

函数的每行代码我都以C语言伪代码做出了详细的注释。我们可以看到这个函数挂起当前协程的操作就是把寄存器保存的当前协程对应的coctx_t结构体中,然后恢复待唤醒协程的coctx_t到寄存器中。我们注意到rsp保存时是先-8后再保存的,原因就是下面协程恢复时使用指令popq %rsp恢复rsp时,会使rsp+8。所以确认过眼神,这就对上了。

扫描二维码,分享此文章