1. 程式人生 > >協程分析之context上下文切換

協程分析之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 [email protected]
#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

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

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%左右。