協程分析之context上下文切換
協程現在已經不是個新東西了,很多語言都提供了原生支援,也有很多開源的庫也提供了協程支援。
最近為了要給tbox增加協程,特地研究了下各大開源協程庫的實現,例如:libtask, libmill, boost, libco, libgo等等。
他們都屬於stackfull協程,每個協程有完整的私有堆疊,裡面的核心就是上下文切換(context),而stackless的協程,比較出名的有protothreads,這個比較另類,有興趣的同學可以去看下原始碼,這裡就不多說了。
那麼現有協程庫,是怎麼去實現context切換的呢,目前主要有以下幾種方式:
- 使用ucontext系列介面,例如:libtask
- 使用setjmp/longjmp介面,例如:libmill
- 使用boost.context,純彙編實現,內部實現機制跟ucontext完全不同,效率非常高,後面會細講,tbox最後也是基於此實現
- 使用windows的GetThreadContext/SetThreadContext介面
- 使用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
借用下里面的圖哈,可以看下:
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%左右。