協程分析之context上下文切換

協程現在已經不是個新東西了,很多語言都提供了原生支持,也有很多開源的庫也提供了協程支持。

最近爲了要給tbox增加協程,特地研究了下各大開源協程庫的實現,例如:libtask, libmill, boost, libco, libgo等等。

他們都屬於stackfull協程,每個協程有完整的私有堆棧,裏面的核心就是上下文切換(context),而stackless的協程,比較出名的有protothreads,這個比較另類,有興趣的同學可以去看下源碼,這裏就不多說了。

那麼現有協程庫,是怎麼去實現context切換的呢,目前主要有以下幾種方式:

  1. 使用ucontext系列接口,例如:libtask
  2. 使用setjmp/longjmp接口,例如:libmill
  3. 使用boost.context,純彙編實現,內部實現機制跟ucontext完全不同,效率非常高,後面會細講,tbox最後也是基於此實現
  4. 使用windows的GetThreadContext/SetThreadContext接口
  5. 使用windows的CreateFiber/ConvertThreadToFiber/SwitchToFiber接口

各個協程協程庫的切換效率的基準測試,可以參考:切換效率基準測試報告

ucontext接口

要研究ucontext,其實只要看下libtask的實現就行了,非常經典,這套接口其實效率並不是很高,而且很多平臺已經標記爲廢棄接口了(像macosx),目前主要是在linux下使用

libtask裏面對不提供此接口的平臺,進行了彙編實現,已達到跨平臺的目的,

ucontext相關接口,主要有如下四個:

  • getcontext:獲取當前context
  • setcontext:切換到指定context
  • makecontext: 用於將一個新函數和堆棧,綁定到指定context中
  • swapcontext:保存當前context,並且切換到指定context

下面給個簡單的例子:

#include <stdio.h>
#include <ucontext.h>

static ucontext_t ctx[3];

static void func1(void)
{
    // 切換到func2
    swapcontext(&ctx[1], &ctx[2]);

    // 返回後,切換到ctx[1].uc_link,也就是main的swapcontext返回處
}
static void func2(void)
{
    // 切換到func1
    swapcontext(&ctx[2], &ctx[1]);

    // 返回後,切換到ctx[2].uc_link,也就是func1的swapcontext返回處
}

int main (void)
{
    // 初始化context1,綁定函數func1和堆棧stack1
    char stack1[8192];
    getcontext(&ctx[1]);
    ctx[1].uc_stack.ss_sp   = stack1;
    ctx[1].uc_stack.ss_size = sizeof(stack1);
    ctx[1].uc_link = &ctx[0];
    makecontext(&ctx[1], func1, 0);

    // 初始化context2,綁定函數func2和堆棧stack2
    char stack2[8192];
    getcontext(&ctx[2]);
    ctx[2].uc_stack.ss_sp   = stack2;
    ctx[2].uc_stack.ss_size = sizeof(stack1);
    ctx[2].uc_link = &ctx[1];
    makecontext(&ctx[2], func2, 0);

    // 保存當前context,然後切換到context2上去,也就是func2
    swapcontext(&ctx[0], &ctx[2]);
    return 0;
}

那這套接口的實現原理是什麼呢,我們可以拿libtask的arm彙編實現,來看下,其他平臺也類似。


/* get mcontext
 *
 * @param mcontext      r0
 *
 * @return              r0
 */
.globl getmcontext
getmcontext:

    /* 保存所有當前寄存器,包括sp和lr */
    str r1, [r0, #4]        // mcontext.mc_r1 = r1
    str r2, [r0, #8]        // mcontext.mc_r2 = r2
    str r3, [r0, #12]       // mcontext.mc_r3 = r3
    str r4, [r0, #16]       // mcontext.mc_r4 = r4
    str r5, [r0, #20]       // mcontext.mc_r5 = r5
    str r6, [r0, #24]       // mcontext.mc_r6 = r6
    str r7, [r0, #28]       // mcontext.mc_r7 = r7
    str r8, [r0, #32]       // mcontext.mc_r8 = r8
    str r9, [r0, #36]       // mcontext.mc_r9 = r9
    str r10, [r0, #40]      // mcontext.mc_r10 = r10
    str r11, [r0, #44]      // mcontext.mc_fp = r11
    str r12, [r0, #48]      // mcontext.mc_ip = r12
    str r13, [r0, #52]      // mcontext.mc_sp = r13
    str r14, [r0, #56]      // mcontext.mc_lr = r14

    // 設置從setcontext切換回getcontext後,從getcontext返回的值爲1
    mov r1, #1              /* mcontext.mc_r0 = 1
                             * 
                             * if (getcontext(ctx) == 0) 
                             *      setcontext(ctx);
                             *
                             * getcontext() will return 1 after calling setcontext()
                             */
    str r1, [r0]

    // 返回0
    mov r0, #0              // return 0
    mov pc, lr

/* set mcontext
 *
 * @param mcontext      r0
 */
.globl setmcontext
setmcontext:

    // 恢復指定context的所有寄存器,包括sp和lr
    ldr r1, [r0, #4]        // r1 = mcontext.mc_r1
    ldr r2, [r0, #8]        // r2 = mcontext.mc_r2
    ldr r3, [r0, #12]       // r3 = mcontext.mc_r3
    ldr r4, [r0, #16]       // r4 = mcontext.mc_r4
    ldr r5, [r0, #20]       // r5 = mcontext.mc_r5
    ldr r6, [r0, #24]       // r6 = mcontext.mc_r6
    ldr r7, [r0, #28]       // r7 = mcontext.mc_r7
    ldr r8, [r0, #32]       // r8 = mcontext.mc_r8
    ldr r9, [r0, #36]       // r9 = mcontext.mc_r9
    ldr r10, [r0, #40]      // r10 = mcontext.mc_r10
    ldr r11, [r0, #44]      // r11 = mcontext.mc_fp
    ldr r12, [r0, #48]      // r12 = mcontext.mc_ip
    ldr r13, [r0, #52]      // r13 = mcontext.mc_sp
    ldr r14, [r0, #56]      // r14 = mcontext.mc_lr

    // 設置getcontext的返回值
    ldr r0, [r0]            // r0 = mcontext.mc_r0

    // 切換到getcontext的返回處,繼續執行
    mov pc, lr              // return

其實說白了,就是對寄存器進行保存和恢復的過程,切換原理很簡單

然後外面只需要用宏包裹下,就行了:

#define setcontext(u)   setmcontext(&(u)->uc_mcontext)
#define getcontext(u)   getmcontext(&(u)->uc_mcontext)

而對於makecontext,主要的工作就是設置 函數指針 和 堆棧 到對應context保存的sp和pc寄存器中,這也就是爲什麼makecontext調用前,必須要先getcontext下的原因。

void makecontext(ucontext_t *uc, void (*fn)(void), int argc, ...)
{
    int i, *sp;
    va_list arg;

    // 將函數參數陸續設置到r0, r1,r2 .. 等參數寄存器中
    sp = (int*)uc->uc_stack.ss_sp + uc->uc_stack.ss_size / 4;
    va_start(arg, argc);
    for(i=0; i<4 && i<argc; i++)
        uc->uc_mcontext.gregs[i] = va_arg(arg, uint);
    va_end(arg);

    // 設置堆棧指針到sp寄存器
    uc->uc_mcontext.gregs[13] = (uint)sp;

    // 設置函數指針到lr寄存器,切換時會設置到pc寄存器中進行跳轉到fn
    uc->uc_mcontext.gregs[14] = (uint)fn;
}

這套接口簡單有效,不支持的平臺還可以通過彙編實現來支持,看上去已經很完美了,但是確有個問題,就是效率不高,因爲每次切換保存和恢復的寄存器太多。

之後可以看下boost.context的實現,就可以對比出來了,下面先簡單講講setjmp的切換。。

setjmp/longjmp接口

libmill裏面的切換主要用的就是此套接口,其實應該是sigsetjmp/siglongjmp,不僅保存了寄存器,還保存了signal mask。。

通過切換效率基準測試報告,可以看到libmill在x86_64架構上,切換非常的快

其實是因爲針對這個平臺,libmill沒有使用原生sigsetjmp/siglongjmp接口,而是自己彙編實現了一套,做了些優化,並且去掉了signal mask的保存。

#if defined(__x86_64__)
#if defined(__AVX__)
#define MILL_CLOBBER \
        , "ymm0", "ymm1", "ymm2", "ymm3", "ymm4", "ymm5", "ymm6", "ymm7",\
        "ymm8", "ymm9", "ymm10", "ymm11", "ymm12", "ymm13", "ymm14", "ymm15"
#else
#define MILL_CLOBBER
#endif
#define mill_setjmp_(ctx) ({\
    int ret;\
    asm("lea     LJMPRET%=(%%rip), %%rcx\n\t"\
        "xor     %%rax, %%rax\n\t"\
        "mov     %%rbx, (%%rdx)\n\t"\
        "mov     %%rbp, 8(%%rdx)\n\t"\
        "mov     %%r12, 16(%%rdx)\n\t"\
        "mov     %%rsp, 24(%%rdx)\n\t"\
        "mov     %%r13, 32(%%rdx)\n\t"\
        "mov     %%r14, 40(%%rdx)\n\t"\
        "mov     %%r15, 48(%%rdx)\n\t"\
        "mov     %%rcx, 56(%%rdx)\n\t"\
        "mov     %%rdi, 64(%%rdx)\n\t"\
        "mov     %%rsi, 72(%%rdx)\n\t"\
        "LJMPRET%=:\n\t"\
        : "=a" (ret)\
        : "d" (ctx)\
        : "memory", "rcx", "r8", "r9", "r10", "r11",\
          "xmm0", "xmm1", "xmm2", "xmm3", "xmm4", "xmm5", "xmm6", "xmm7",\
          "xmm8", "xmm9", "xmm10", "xmm11", "xmm12", "xmm13", "xmm14", "xmm15"\
          MILL_CLOBBER\
          );\
    ret;\
})
#define mill_longjmp_(ctx) \
    asm("movq   (%%rax), %%rbx\n\t"\
        "movq   8(%%rax), %%rbp\n\t"\
        "movq   16(%%rax), %%r12\n\t"\
        "movq   24(%%rax), %%rdx\n\t"\
        "movq   32(%%rax), %%r13\n\t"\
        "movq   40(%%rax), %%r14\n\t"\
        "mov    %%rdx, %%rsp\n\t"\
        "movq   48(%%rax), %%r15\n\t"\
        "movq   56(%%rax), %%rdx\n\t"\
        "movq   64(%%rax), %%rdi\n\t"\
        "movq   72(%%rax), %%rsi\n\t"\
        "jmp    *%%rdx\n\t"\
        : : "a" (ctx) : "rdx" \
    )
#else
#define mill_setjmp_(ctx) \
    sigsetjmp(*ctx, 0)
#define mill_longjmp_(ctx) \
    siglongjmp(*ctx, 1)
#endif

經過測試分析,其實libc自帶的sigsetjmp/siglongjmp在不同平臺下,效率上表現差異很大,而且切換也比setjmp/longjmp的慢了不少

所以libmill除了優化過的x86_64平臺,在其他arch上切換效果並不是很理想,完全依賴libc的實現效率。。

因此後來再封裝tbox的協程庫的時候,並沒有考慮此方案。

windows的GetThreadContext/SetThreadContext接口

這套接口,我之前用來封裝setcontext/getcontext的時候,也實現並測試過,效果非常不理想,非常的慢,比用libtask那套純彙編的實現慢了10倍左右,直接放棄了

不過這套接口用起來還是很方便,跟ucontext類似,完全可以用來模擬封裝成ucontext的使用方式,例如:


// getcontext
GetThreadContext(GetCurrentThread(), mcontext);

// setcontext
SetThreadContext(GetCurrentThread(), mcontext);

而makecontext,我貼下之前寫的一些實現,不過現在已經廢棄了,僅供參考:

tb_bool_t makecontext(tb_context_ref_t context, tb_pointer_t stack, tb_size_t stacksize, tb_context_func_t func, tb_cpointer_t priv)
{
    // check
    LPCONTEXT mcontext = (LPCONTEXT)context;
    tb_assert_and_check_return_val(mcontext && stack && stacksize && func, tb_false);

    // make stack address
    tb_long_t* sp = (tb_long_t*)stack + stacksize / sizeof(tb_long_t);

    // push arguments
    tb_uint64_t value = tb_p2u64(priv);
    *--sp = (tb_long_t)(tb_uint32_t)(value);
    *--sp = (tb_long_t)(tb_uint32_t)(value >> 32);

    // push return address(unused, only reverse the stack space)
    *--sp = 0;

    /* save function and stack address
     *
     * sp + 8:  arg2
     * sp + 4:  arg1                         
     * sp:      return address(0)   => esp 
     */
    mcontext->Eip = (tb_long_t)func;
    mcontext->Esp = (tb_long_t)sp;
    tb_assert_static(sizeof(tb_long_t) == 4);

    // save and restore the full machine context 
    mcontext->ContextFlags = CONTEXT_FULL;

    // ok
    return tb_true;
}

原理跟libtask的那個類似,就是修改esp和eip寄存器而已,具體實現可以參考我之前的commit

windows的fibers接口

這套接口,目前還沒測試過,不過看msdn介紹,使用還是很方便的,不過部分xp系統上,並不提供此接口,需要較高版本的系統支持

因此爲了考慮跨平臺,tbox暫時沒去考慮使用,有興趣的同學可以研究下。

boost.context

其實一開始tbox是參考libtask的ucontext彙編實現,封裝了一套context切換,當時其實已經封裝的差不多了,但是後來做benchbox的基準測試

把boost的切換一對比,直接就被秒殺了,哎。。然後去看boost的context實現源碼,雖然對boost本身並不是太喜歡,但是底層的context是實現,確實非常精妙,不得不佩服。

它主要有兩個接口,一個make_fcontext(),一個jump_fcontext(),我在tbox的平臺庫裏面參考其實現,進行了封裝,使用方式跟boost類似,因此直接以tbox的使用爲例:

static tb_void_t func1(tb_context_from_t from)
{
    // 獲取切換時傳入的contexts參數
    tb_context_ref_t* contexts = (tb_context_ref_t*)from.priv;

    // 保存原始context
    contexts[0] = from.context;

    // 切換到func2
    from = tb_context_jump(contexts[2], contexts);

    // 從func2返回後,切換回main
    tb_context_jump(contexts[0], tb_null);
}
static tb_void_t func2(tb_context_from_t from)
{
    // 獲取切換時傳入的contexts參數
    tb_context_ref_t* contexts = (tb_context_ref_t*)from.priv;

    // 切換到func1
    from = tb_context_jump(from.context, contexts);

    // 從func1返回後,切換回main
    tb_context_jump(contexts[0], tb_null);
}

int main(int argc, char** argv)
{
    // the stacks
    static tb_context_ref_t contexts[3];
    static tb_byte_t        stacks1[8192];
    static tb_byte_t        stacks2[8192];

    // 通過stack1和func1生成context1
    contexts[1] = tb_context_make(stacks1, sizeof(stacks1), func1);

    // 通過stack2和func2生成context2
    contexts[2] = tb_context_make(stacks2, sizeof(stacks2), func2);

    // 切換到func1,並且傳入contexts作爲參數
    tb_context_jump(contexts[1], contexts);
}

其中tb_context_make相當於boost的make_fcontext, tb_context_jump相當於boost的jump_fcontext

相比ucontext,boost的切換模式,少了單獨對context進行保存(getcontext)和切換(setcontext)過程,而是把兩者合併到一起,通過jump_fcontext接口實現直接切換。

這樣做有個好處,就是更加容易進行優化,使得整個切換過程更加的緊湊,我們先來看下macosx平臺x86_64的實現,這個比較簡單易懂些。。

這裏我就直接貼tbox的代碼了,實現差不多的,只不過多了些註釋而已。

/* make context (refer to boost.context)
 *
 *             -------------------------------------------------------------------------------
 * stackdata: |                                                |         context        |||||||
 *             -------------------------------------------------------------------------|-----
 *                                                                             (16-align for macosx)
 *
 *
 *             -------------------------------------------------------------------------------
 * context:   |   r12   |   r13   |   r14   |   r15   |   rbx   |   rbp   |   rip   |   end   | ...
 *             -------------------------------------------------------------------------------
 *            0         8         16        24        32        40        48        56        |
 *                                                                                  |  16-align for macosx
 *                                                                                  |
 *                                                                       esp when jump to function
 *
 * @param stackdata     the stack data (rdi)
 * @param stacksize     the stack size (rsi)
 * @param func          the entry function (rdx)
 *
 * @return              the context pointer (rax)
 */
function(tb_context_make)

    // 保存棧頂指針到rax
    addq %rsi, %rdi
    movq %rdi, %rax

    /* 先對棧指針進行16字節對齊
     *
     *                      
     *             ------------------------------
     * context:   | retaddr |    padding ...     |
     *             ------------------------------
     *            |         | 
     *            |     此處16字節對齊
     *            |
     *  esp到此處時,會進行ret
     *
     * 這麼做,主要是因爲macosx下,對調用棧佈局進行了優化,在保存調用函數返回地址的堆棧處,需要進行16字節對齊,方便利用SIMD進行優化
     */
    movabs $-16, %r8
    andq %r8, %rax

    // 保留context需要的一些空間,因爲context和stack是在一起的,stack底指針就是context
    leaq -64(%rax), %rax

    // 保存func函數地址到context.rip
    movq %rdx, 48(%rax)

    /* 保存__end地址到context.end,如果在在func返回時,沒有指定jump切換到有效context
     * 那麼會繼續會執行到此處,程序也就退出了
     */
    leaq __end(%rip), %rcx
    movq %rcx, 56(%rax)

    // 返回rax指向的棧底指針,作爲context返回
    ret 

__end:
    // exit(0)
    xorq %rdi, %rdi
#ifdef TB_ARCH_ELF
    call _exit@PLT
#else
    call __exit
#endif
    hlt

endfunc

/* jump context (refer to boost.context)
 *
 * @param context       the to-context (rdi)
 * @param priv          the passed user private data (rsi)
 *
 * @return              the from-context (context: rax, priv: rdx)
 */
function(tb_context_jump)

    // 保存寄存器,並且按佈局構造成當前context,包括jump()自身的返回地址retaddr(rip)
    pushq %rbp
    pushq %rbx
    pushq %r15
    pushq %r14
    pushq %r13
    pushq %r12

    // 保存當前棧基址rsp,也就是contex,到rax中
    movq %rsp, %rax

    // 切換到指定的新context上去,也就是切換堆棧
    movq %rdi, %rsp

    // 然後按context上的棧佈局依次恢復寄存器
    popq %r12
    popq %r13
    popq %r14
    popq %r15
    popq %rbx
    popq %rbp

    // 獲取context.rip,也就是make時候指定的func函數地址,或者是對方context中jump()調用的返回地址
    popq %r8

    // 設置返回值(from.context: rax, from.priv: rdx),也就是來自對方jump()的context和傳遞參數
    movq %rsi, %rdx

    // 傳遞當前(context: rax, priv: rdx),作爲function(from)函數調用的入口參數
    movq %rax, %rdi

    /* 跳轉切換到make時候指定的func函數地址,或者是對方context中jump()調用的返回地址
     *
     * 切換過去後,此時的棧佈局如下:
     *
     * end是func的返回地址,也就是exit
     *
     *             -------------------------------
     * context: .. |   end   | args | padding ... |
     *             -------------------------------
     *             0             8        
     *             |             |  
     *            rsp   16-align for macosx
     */
    jmp *%r8

endfunc

關於apple棧佈局16字節對齊優化問題,可以參考:http://fabiensanglard.net/macosxassembly/index.php

借用下里面的圖哈,可以看下:

macosx_stack

boost的context和stack是一起的,棧底指針就是context,設計非常巧妙,切換context就是切換stack,一舉兩得,但是這樣每次切換就必須更新context

因爲每次切換context後,context地址都會變掉。

// 切換返回時,需要更新from.context的地址
from = tb_context_jump(from.context, contexts);

現在可以和getcontext/setcontext對比下,就可以看出,這種切換方式的一些優勢:

1. 保存和恢復寄存器數據,在一個切換接口中,更加容易進行優化
2. 通過stack基棧作爲context,切換棧相當於切換了context,一舉兩得,指令數更少
3. 通過push/pop操作保存寄存器,比mov等方式指令字節數更少,更加精簡
4. 對參數、可變寄存器沒去保存,僅保存部分必須的寄存器,進一步減少指令數

關於boost macosx i386下的bug

爲了實現跨平臺,boost下各個架構的實現,我都研究了一遍,發現macosx i386的實現,是有問題的,運行會掛掉,裏面直接照搬了linux elf的i386實現版本。

估計macosx i386用的不多,所以沒去做測試,後來發現,原來macosx i386下jump()返回from(context, priv)的結構體並不是基於棧的

而是使用eax, edx返回,因此tbox裏面針對這個架構,重新調整stack佈局,重寫了一套自己的實現。

關於boost windows i386下的優化

其實在windows下,返回from(context, priv)的結構體,也是用的eax, edx,而不是像linux elf那樣基於棧的,因此實現上效率會高很多。

但是,boost裏面,卻像elf那個版本一樣,還是採用了一個跳板,進行二次跳轉後,才切換到context上去,是沒有必要的。

在boost裏面的跳板代碼,類似像這樣(摘錄自tbox elf i386的實現):

__entry:

    /* pass arguments(context: eax, priv: edx) to the context function
     *
     *              patch __end
     *                  |
     *                  |        old-context
     *              ----|------------------------------------
     * context: .. | retval | context |   priv   |  padding  |  
     *              -----------------------------------------
     *             0        4     arguments 
     *             |        |
     *            esp    16-align
     *           (now)
     */
    movl %eax, (%esp)
    movl %edx, 0x4(%esp)

    // retval = the address of label __end
    pushl %ebp

    /* jump to the context function entry
     *
     * @note need not adjust stack pointer(+4) using 'ret $4' when enter into function first
     */
    jmp *%ebx

由於elf i386下,返回from結構體是基於棧的,所以進入function入口的棧,和切換到對方jump()返回處的棧,並不是完全平衡的,因此需要一個跳板區分對待

stack佈局上也需要特殊處理,而windows i386的返回,只需要eax/edx就足夠,沒必要再去使用這個跳板。

因此,tbox裏面針對這個平臺,進行了優化,重新調整了棧佈局,省去跳板操作,直接進行跳轉,實測切換效率比boost的實現提升30%左右。


個人主頁:TBOOX開源工程
原文出處:http://tboox.org/cn/2016/10/28/coroutine-context/

發佈了13 篇原創文章 · 獲贊 8 · 訪問量 6萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章