c++雲風coroutine庫解析

雲風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);
}

參考:

  1. 雲風的BLOG
  2. libco協程概述
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章