雲風coroutine庫是一個C語言實現的輕量級協程庫,源碼簡潔易懂,可以說是了(ru)解(keng)協程原理的最好源碼資源。
我在之前的文章中,藉助騰訊開源的libco,對C/C++的協程實現有了一個簡單介紹,參考博客。其實libco和雲風coroutine有很多相似的思想,只不過實現的方式不同而已,雲風庫只是提供了一種實現思路,並沒有對hook進行處理,而libco則是工業級的協程庫實現。通過兩者源碼閱讀分析,可以比較出不同的實現方式的差異,多多思考源碼庫的作者爲什麼要這麼設計,自己能不能對其進行改進,這樣對自己的提升很有幫助。
設計思路分析
雲風庫主要利用ucontext簇函數進行協程上下文切換,ucontext簇函數的最大有點就是簡單易用,但是切換的性能不如libco設計的彙編邏輯(主要原因是ucontext內部實現上下文切換了很多不需要的寄存器,而libco彙編實現的切換則更加簡潔直接),主要包括一下四個函數:
//獲取當前的上下文保存到ucp
getcontext(ucontext_t *ucp)
//直接到ucp所指向的上下文中去執行
setcontext(const ucontext_t *ucp)
//創建一個新的上下文
makecontext(ucontext_t *ucp, void (*func)(), int argc, ...)
//將當前上下文保存到oucp,然後跳轉到ucp上下文執行
int swapcontext(ucontext_t *oucp, const ucontext_t *ucp)
//ucontext結構體
typedef struct ucontext{
//當這個上下文終止後,運行uc_link指向的上下文
//如果爲NULL則終止當前線程
struct ucontext* uc_link;
// 爲該上下文的阻塞信號集合
sigset_t uc_sigmask;
//該上下文使用的棧
stack_t uc_stack;
//保持上下文的特定寄存器
mcontext_t uc_mcontext;
...
}ucontext_t
API的設計方面,雲風庫的API設計的非常簡潔,主要如下的設計:
//協程對象的四種狀態
#define COROUTINE_DEAD 0
#define COROUTINE_READY 1
#define COROUTINE_RUNNING 2
#define COROUTINE_SUSPEND 3
//新建協程調度器對象
struct schedule * coroutine_open(void);
//關閉協程調度器
void coroutine_close(struct schedule *);
//創建協程對象
int coroutine_new(struct schedule *, coroutine_func, void *ud);
//執行協程對象(啓動或繼續)
void coroutine_resume(struct schedule *, int id);
//返回協程對象的狀態
int coroutine_status(struct schedule *, int id);
//返回正在執行的協程ID
int coroutine_running(struct schedule *);
//yeild當前執行的協程,返回到主協程
void coroutine_yield(struct schedule *);
在協程棧的設計中,雲風庫也是選擇共享棧的實現,即每個協程自己保持棧中的數據,每當resume運行的時候,將自己的數據copy到運行棧上,每當yield的時候,將運行棧的數據(首先要找到棧底和棧頂)保存在自己協程結構體中。這種方法優勢在於只需要一開始初始化一大塊棧內存(雲風庫默認是1M),運行時數據放在其上即可,不會考慮到爆棧的問題,相比於每個協程一個自己的棧,棧內存的利用率要高很多。缺點在於,每次協程切換都會有用戶態中的copy過程。接下來可以看其如何實現。
源碼分析
接下來看其主要的邏輯實現。雲風庫中主要有兩個結構體。一個是調度器,一個是協程。
//協程調度器
struct schedule {
char stack[STACK_SIZE]; //默認大小1MB,是一個共享棧,所有協程運行時都在其上
ucontext_t main; //主協程上下文
int nco; //協程調度器中存活協程個數
int cap; //協程調度器管理最大容量。最大支持多少協程。當nco >= cap時候要擴容
int running; //正在運行的協程ID
struct coroutine **co; //一維數組,數組元素是協程指針
};
//協程
struct coroutine {
coroutine_func func; //協程執行函數
void *ud; //協程參數
ucontext_t ctx; //協程上下文
struct schedule * sch; //協程對應的調度器
ptrdiff_t cap; //協程自己保存棧的最大容量(stack的容量)
ptrdiff_t size; //協程自己保存的棧當前大小(stack的大小)
int status; //協程狀態
char *stack; //當前協程自己保存的棧數據,因爲是共享棧的設計,
//即每個協程都在同一個棧空間中切換,所以每個協程在切換出來後要保存自己的棧內容
};
接下來看一下coroutine_resume的源碼,這個函數是開啓指定協程,可以看到有兩種情況,一種是協程第一次執行,狀態從COROUTINE_READY -> COROUTINE_RUNNING,另一種是協程之前運行過但是yield了,再次執行,狀態從COROUTINE_SUSPEND -> COROUTINE_RUNNING。
//mainfunc是對協程函數的封裝,裏面運行了用戶提供的協程函數,並在結束後刪除對應的協程
static void
mainfunc(uint32_t low32, uint32_t hi32) {
uintptr_t ptr = (uintptr_t)low32 | ((uintptr_t)hi32 << 32);
struct schedule *S = (struct schedule *)ptr;
int id = S->running;
struct coroutine *C = S->co[id];
C->func(S,C->ud); //運行對應函數
_co_delete(C);
S->co[id] = NULL;
--S->nco;
S->running = -1;
}
//指定的協程開始(繼續)運行
void
coroutine_resume(struct schedule * S, int id) {
//首先確保沒有正在運行的協程,並且id滿足條件
assert(S->running == -1);
assert(id >=0 && id < S->cap);
struct coroutine *C = S->co[id];
if (C == NULL)
return;
int status = C->status;
switch(status) {
case COROUTINE_READY:
//此時協程是剛剛新建的,還沒運行過,切換上下文,getcontext初始化即將要運行協程的上下文,
getcontext(&C->ctx);
C->ctx.uc_stack.ss_sp = S->stack; //設置共享棧(即設置當前協程中的棧爲運行棧,運行棧就是共享棧,一開始就分配好的1M空間)
C->ctx.uc_stack.ss_size = STACK_SIZE;
C->ctx.uc_link = &S->main; //uc_link是當前上下文終止後,執行運行主協程的上下文
S->running = id;
C->status = COROUTINE_RUNNING;
//注意這裏將S作爲參數,傳到mainfunc裏面,但是先劃分成兩個uint32_t,然後再在mainfunc中合併
uintptr_t ptr = (uintptr_t)S;
makecontext(&C->ctx, (void (*)(void)) mainfunc, 2, (uint32_t)ptr, (uint32_t)(ptr>>32));
swapcontext(&S->main, &C->ctx);
break;
case COROUTINE_SUSPEND:
//此時協程已經被yield過,memcpy將協程自己的棧中內存copy到運行棧
//共享棧的缺點就是在yield和resume的時候要自己進行copy,將協程自己保存的棧內容與運行棧之間進行copy
memcpy(S->stack + STACK_SIZE - C->size, C->stack, C->size);
S->running = id;
C->status = COROUTINE_RUNNING;
swapcontext(&S->main, &C->ctx);
break;
default:
assert(0);
}
}
接下來看一下coroutine_yield的實現。yield就是將協程調度器中當前運行的協程中斷,然後用戶態下切換另一個協程運行。至於中斷的原因有很多,比如在等IO的時候,或者等待系統調用,或者等待網絡數據,雲風庫中沒有對其實現,具體可以去看看libco中hook了哪些函數。
這裏的有一個問題,就是在yield的過程中,要將運行棧的數據copy出來,如何找到運行棧中的數據呢?我們知道,在進程地址空間中,棧是從高地址向低地址延伸的,也就是說棧底在高地址,棧頂在低地址,要想copy棧中的數據,只需要找到棧頂和棧底地址,將其中的數據memcpy出來即可。棧底很好找,即爲S->stack + STACK_SIZE,棧頂則可以利用一個dummy對象,將S->stack與dummy對象的地址相減,即爲棧目前的長度。
//保存當前協程的協程棧,因爲coroutine是基於共享棧的,所以協程的棧內容需要單獨保存起來。
static void
_save_stack(struct coroutine *C, char *top) {
//利用dump找到棧頂位置(最低地址)
char dummy = 0;
assert(top - &dummy <= STACK_SIZE);
if (C->cap < top - &dummy) {
free(C->stack);
C->cap = top-&dummy;
C->stack = malloc(C->cap);
}
C->size = top - &dummy;
memcpy(C->stack, &dummy, C->size);
}
//切換出當前正在運行的協程,切換到主協程運行,因爲主協程中有while(),並且兩個子協程相繼切換
void
coroutine_yield(struct schedule * S) {
int id = S->running;
assert(id >= 0);
struct coroutine * C = S->co[id];
assert((char *)&C > S->stack);
_save_stack(C,S->stack + STACK_SIZE);//棧是從高地址向低地址發展的,S->stack + STACK_SIZE是棧底(最高地址)
C->status = COROUTINE_SUSPEND;
S->running = -1;
swapcontext(&C->ctx , &S->main);
}
參考: