協程又被稱爲微線程,不過其實這樣的稱呼無形中爲理解協程增加了一點阻礙。協程本質上是在一個線程裏面,因此不管協程數量多少,它們都是串行運行的,也就是說不存在同一時刻,屬於同一個線程的不同協程同時在運行。因此它本身避免了所有多線程編程可能導致的同步問題。
協程的行爲有點像函數調用,它和函數調用的不同在於,對於函數調用來說,假如A函數調用B函數,則必須等待B函數執行完畢之後程序運行流程纔會重新走回A,但是對於協程來說,如果在協程A中切到協程B,協程B可以選擇某個點重新回到A的執行流,同時允許在某個時刻重新從A回到B之前回到A的那個點,這在函數中是不可能實現的。因爲函數只能一走到底。用knuth的話來說:
子程序就是協程的一種特例
既然允許協程中途切換,以及後期重新從切換點進入繼續執行,說明必須有數據結構保存每個協程的上下文信息。這就是ucontext_t,而linux中包含以下幾個系統函數對ucontext_t進行初始化,設置,以及基於ucontext_t進行協程間的切換:
- int getcontext(ucontext_t *);
- int setcontext(const ucontext_t *);
- void makecontext(ucontext_t , (void )(), int, …);
- int swapcontext(ucontext_t , const ucontext_t );
下面根據雲風的精簡協程庫來說明如何用這些函數和ucontext_t來實現一個協程池。
coroutine的實現分析
coroutine裏面主要是由協程管理結構schedule和以下幾個核心函數組成 :
- coroutine_resume
- coroutine_yield
coroutine由用戶協程和一個管理作用的協程組成。每次協程切換時,用戶協程必須先切換到管理協程,然後管理協程再負責切換到其他的用戶協程,不能直接從用戶協程切換到其他用戶協程。從以上兩個函數來說,coroutine_resume就是從管理協程切換到用戶協程的入口,coroutine_yield是從用戶協程切換到管理協程的入口。
下面着重分析一下這兩個函數:
void
coroutine_resume(struct schedule * S, int 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) {
//第一次被切換到的用戶協程處於COROUTINE_READY
case COROUTINE_READY:
getcontext(&C->ctx);
C->ctx.uc_stack.ss_sp = S->stack;
C->ctx.uc_stack.ss_size = STACK_SIZE;
C->ctx.uc_link = &S->main;
S->running = id;
C->status = COROUTINE_RUNNING;
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:
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中協程有三種狀態:
#define COROUTINE_DEAD 0
#define COROUTINE_READY 1
#define COROUTINE_RUNNING 2
#define COROUTINE_SUSPEND 3
協程第一次運行之前處於COROUTINE_READY 狀態,正在運行的協程處於COROUTINE_RUNNING 狀態,讓出CPU,切換到其他協程的協程處於COROUTINE_SUSPEND。
當管理協程調用coroutine_resume試圖啓動一個處於COROUTINE_READY 的協程時,coroutine首先初始化這個用戶協程對應的context,然後通過調用makecontext設置這個用戶協程的入口地址。最後調用swapcontext完成管理協程和用戶協程的切換,swapcontext(&S->main, &C->ctx) 將當前的上下文保存在S->main中,然後切換到C->ctx對應的執行流。
這裏先省略掉後面的代碼,先解析coroutine_yield,回頭再繼續看coroutine_resume。
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);
C->status = COROUTINE_SUSPEND;
S->running = -1;
swapcontext(&C->ctx , &S->main);
}
因爲協程的切換完全在用戶態,和系統內部的線程不同,它是非搶佔式的,因此必須依靠用戶自覺讓出CPU 才能讓其他協程運行,coroutine_yield函數的作用就是主動讓出CPU,每當用戶協程想讓出CPU時,就調用coroutine_yield,coroutine_yield將完成從用戶協程切到管理協程的動作。
回到coroutine_yield函數,程序首先獲得正在運行的協程的控制結構體,然後將該協程的運行上下文(主要是堆棧)保存在這個結構體中,最後通過swapcontext函數切換到控制協程。我們可以看到,爲了能夠讓這個用戶協程下次還能回到這個執行點繼續執行,必須保留它的執行上下文,這部分工作主要是在_save_stack函數中完成。下面看一下_save_stack函數的實現:
static void
_save_stack(struct coroutine *C, char *top) {
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);
}
該函數的第二個指針代表當前協程運行棧的棧頂,從coroutine_yield我們知道
top = S->stack + STACK_SIZE
爲什麼會是這樣?回到coroutine_resume函數,在coroutine_resume函數中,對於即將被調度的用戶協程,會做如下的初始化:
C->ctx.uc_stack.ss_sp = S->stack;
C->ctx.uc_stack.ss_size = STACK_SIZE;
這兩行代碼的意思就是將該協程的棧頂設置爲S->stack + STACK_SIZE。而下面的
makecontext(&C->ctx, (void (*)(void)) mainfunc, 2, (uint32_t)ptr, (uint32_t)(ptr>>32));
設置該協程的入口地址,也就是說該協程啓動時,將從mainfunc進入,並使用S->stack + STACK_SIZE作爲棧頂控制協程的運行棧。
從上面對用戶協程的棧頂初始化過程中可以看到,用戶協程在運行過程中用的都是struct schedule的stack[STACK_SIZE]作爲棧空間,由於每個協程運行時都會使用到這個空間,因此還必須爲每個協程單獨建立一個屬於自己的棧空間。下面回到_save_stack函數
C->cap表示該控制結構對應的協程的私有棧空間的大小,因此
if(C->cap < top - &dummy)
的作用是判斷該用戶協程的私有棧空間能否大於它運行時棧空間。前面說過top 表示棧頂,而dummy表示該用戶協程當前的棧底(棧由高地址向低地址生長),所有top-dummy就表示該用戶協程運行時棧所佔空間。如果用來保存私有棧的C->stack的大小不能放下運行時棧空間,則需要重新申請。
memcpy(C->stack, &dummy, C->size);
這裏就將運行時棧的數據全部拷貝到該協程控制結構的C->stack中,這樣,以後別的協程使用S->stack做運行時棧空間時,就不會覆寫掉該協程原來棧中的數據了。
回到coroutine_yield,現在當前的協程的棧記錄已經被保存在了C->stack中,最後調用
swapcontext(&C->ctx , &S->main);
切換到控制協程。這個函數會保存上下文相關的寄存器到C->ctx中。下次這個協程被重新啓動時,就會從這裏繼續運行下去(調用coroutine_yield函數的下一條語句)。
在前面的coroutine_resume中我們知道,上次main協程是從
swapcontext(&S->main, &C->ctx);
break;
切換出去的,因此回到main協程之後,將會從break語句開始執行。一般來說,買你協程會不斷循環調用coroutine_resume啓用協程池中的協程,直到所有的用戶協程結束。因此我們假設main協程再次從coroutine_resume進入,此時main已經選好了下一個被調度的用戶協程,他的控制結構是:
struct coroutine *C = S->co[id];
如前面所說,如果該協程之前沒有運行過,則走
case COROUTINE_READY:
就是一些進行初始化的操作,如果前面運行過了,但還沒有死亡,則走,
case COROUTINE_SUSPEND:
下面我們看一下這個分支:
首先爲了啓動這個協程,必須恢復它的上下文信息,上下文信息包括:
1. 運行棧
2. 寄存器
在case COROUTINE_READY中我們知道
C->ctx.uc_link = &S->main;
C的運行時棧空間始終是在S->main中的,因此恢復棧空間,其實就是將coroutine_yield中保存到各自私有的C->stack空間中的數據恢復到S->main中:
memcpy(S->stack + STACK_SIZE - C->size, C->stack, C->size);
這裏有點講究,因爲棧是從高地址向低地址生長的,所以需要從高地址向低地址拷貝。[記住_save_stack中在保存棧空間時,C->stack中的低地址對應運行時棧空間(S->stack-S->stack+STACK_SIZE )的高地址 : memcpy(C->stack, &dummy, C->size);]
coroutine_resume最後調用swapcontext重新啓動了一個新的用戶協程,這樣就完成了從一個協程切換到另一個協程的所有操作。
綜上
本質上協程就是通過一個記錄上下文的結構和若干可以切換,修改,獲取上下文信息的函數來達到用戶態切換執行流的功能。這裏一個核心是我們可以爲各個協程定義一個連續內存空間,然後將這個連續內存空間的地址(uc_stack.ss_sp = S->stack)和大小(uc_stack.ss_size)設置到上下文結構中作爲該協程的運行棧空間,通過這兩個變量就可以計算出棧頂指針(uc_stack.ss_sp + uc_stack.ss_size),所以本質上在用戶態定義了棧空間。在協程切換是,直接使用上下文結構中的棧頂指針作爲esp,完成多個執行流之間的棧空間分離。自然就可以做到模擬線程的效果。
這裏附一個傳送門,介紹swapcontext, makecontext, setcontext, getcontext的實現,這樣可以更好理解協程的原理。