??????最近不知道咋回事突然對協程的原理挺感興趣,雖然之前也學習過Go語言,接觸過協程,但是其實并不太了解具體的原理,尤其看到知乎上stackless與stackful之間作比較的文章,個人表示真心看不懂,但是帶著越是看不懂越要裝逼的沖動,上網查了查一些協程的實現,目前感覺比較知名的有:微信libco、libgo、Go語言作者之一的Russ Cox個人寫的libtask、Java協程庫以及本文要剖析的云風寫的coroutine。不廢話,先上圖:
從上圖可以看出coroutine有以下兩個特點:
第一:代碼少,僅僅用不到300行代碼就實現了協程的核心邏輯,對此只有一個大寫的服。
第二:用純C語言寫的(沒有c++);說實話,個人自認為C語言學得還行,但是C++老是學不會。
看懂了這個,我感覺就可以看看其他稍微復雜點的實現(加入了poll等 因為目前這個簡單的還沒法做到IO阻塞時自動yield)。
在看之前,先聊點必備知識點,這是理解協程實現的核心。
getcontext、setcontext、makecontext、swapcontext
這四個函數是Linux提供的系統函數(忘了跟大家說了 云風這個版本只可以在linux下運行 雖然windows也有fiber這種東西 但是我感覺應該木人感興趣....),理解了這四個函數,其實這個代碼基本80%的難點就搞定了(剩下20%在于要理解函數調用的棧機制)。
其實context這個詞在編程中是個很重要的詞,Java Spring框架中有ApplicationContext、OSGI框架中有BundleContext、線程也有自己存活的一個Context等等,其實context就是代表所必需的一個環境信息,有了context就可以決定你的一切走向。簡單說,如果你所需要的僅僅是一個變量a=1,那么我們也可以說a=1就是你這個場景下的context。再比如線程切換的時候,總不能讓線程切換回來的時候重新開始吧,他曾經走到過的地方,修改過的變量,總得給人家保存到一個地方吧,那么這些需要保存的信息其實也是這個線程能繼續運行所依賴的context。只不過線程切換的涉及到CPU ring的變化(ring3 用戶態 ring0 內核態),此時比較難處理的是每個ring下有自己的特用棧,而協程-作為輕量級的線程,不涉及到CPU ring的轉變,僅僅在用戶態模擬了這種切換(待會兒分析完coroutine就知道啥意思啦),這也是協程會比線程性能高的本質原因所在(Do less)。
getcontext,顧名思義,將當前context保存起來;setcontext,改變當前的context;makecontext這個畢竟牛逼,是制作一個你想要的context;最后swapcontext從一個context切換到另外一個context。先來個簡單例子練練手:
#include <stdio.h>
#include <ucontext.h>
void main(){
char isVisit = 0;
ucontext_t context1, context2; //ucontext_t也是linux提供的類型 用來存儲當前context的
getcontext(&context1);
printf("I come here\n");
if(!isVisit){
isVisit = 1;
swapcontext(&context2, &context1);
}
printf("end of main!");
}
猜猜這個執行結果是啥? 先上答案:
可以看出 I come here執行了兩次, why?我們來一句一句的解釋:首先聲明了兩個變量context1和context2,可以把這兩個變量理解為容器,專門用來放當前環境的,第三行的時候執行了getcontext(&context1)意思是把當前上下文環境保存到context1這個變量中,然后打印了第一次“I come here”,然后運行到isVisit,因為一開始isVisit=0,所以會進入if語句之中,運行到swapcontext時候,這個函數的含義是把當前上下文環境保存到context2之中,同時跳到context1對應的上下文環境之中。由于context1當時保存的是getcontext調用時的上下文環境中(運行程序到第幾行也是上下文環境中一個元素),所以swapcontext調用完畢后,又會切換到printf對應的這一行,再次打印出第二次的“I come here”,但是此時之前isVisit已經設置為1了,所以if不會進入了,直接到“end of main”了,程序結束!其實這里還是有個東西沒有說清楚,上下文環境到底包含什么? 我們知道真正執行指令歸根結底還是CPU,而CPU在運行程序時會借助于很多寄存器,比如EIP(永遠指向下一條要執行程序的地址)、ESP(stack pointer棧指針)、EBP(base pointer基址寄存器)等等,這些其實就是所謂的上下文環境,比如EIP,程序在執行時要靠這個寄存器指示下一步去執行哪條指令,如果你切換到其他協程(線程也如此),回來的時候EIP已經找不到了,那你不悲劇了?那協程(線程也如此)豈不是又要重頭來過?那這個時候顯然需要一塊內存去把這個上下文環境保存住,下次回來的時候從內存拿出來接著執行就行,就跟沒有切換一個樣。針對協程來說,上文Linux提供的ucontext_t這個變量就起到這個存儲上下文的作用(線程切換的上下文存儲涉及到ring的改變 由操作系統完成的 而協程切換上下文保存需要咱們自己搞定 操作系統根本不參與 不過這樣也減少了用戶態與內核態的切換)。結合這段話再回頭看看上面的程序,應該會有新的體會。
Let us move forward,繼續再看下面一段程序:
#include <stdio.h>
#include <ucontext.h>
#define STACK_SIZE (1024*1024)
char stack[STACK_SIZE];
void test(int a){
char dummy = 12;
int hello = 9;
printf("test %d\n",a);
}
void main(){
char isVisit = 0;
ucontext_t context1, context2;
getcontext(&context1);
printf("I come here\n");
context1.uc_stack.ss_sp = stack;
context1.uc_stack.ss_size = STACK_SIZE;
context1.uc_link = &context2;
makecontext(&context1, (void (*)(void))test, 1, 10);
if(!isVisit){
isVisit = 1;
swapcontext(&context2, &context1);
printf("I come to if!\n");
}
printf("end of main!\n");
}
先上結果:
I come here
test 10
I come to if!
end of main!
這里又加了一個makecontext,這個可是context函數界的老大哥,協程核心全靠他,getcontext只是獲取一個當前上下文,而makecontext卻能在得到當前上下文的基礎上對其進行社會主義改造,比如我們上面這段代碼中修改了棧的信息,讓test函數執行的時候使用stack[STACK_SIZE]作為其棧空間(函數調用過程需要棧保存返回地址 函數參數 局部變量),makecontext第三個參數代表test函數需要幾個參數,因為我們的test函數只需要一個int a,所以這里傳入1,后面的10就是具體參數值,再比如coroutine源碼中有下面一個片段:
makecontext(&C->ctx, (void (*)(void)) mainfunc, 2, (uint32_t)ptr, (uint32_t)(ptr>>32));
想必大家也知道第三個參數2啥意思了。這里比較重要的是兩個點:
1. makecontext中第二個參數是一個函數指針,意思是當context1生效時便執行test函數(可以理解為test函數執行的上下文環境就是context1)。
2. 當test函數執行完畢后,會跳到context1.uc_link對應的環境上下文(上面代碼設置為context2對應的上下文)。
這兩點需要用心體會,結合上述代碼便可以更好的理解。當然,我們這里用gdb調試一下,幫忙大家理解這個過程(有意搞了一個dummy和hello變量在test函數 貌似沒有用 實則很有用)。
具體操作見下圖:
幾個注意點,一是注意gcc 別忘了加上-g參數,這是為了生成可以調試的信息,否則gdb無法調試。gdb常用的調試命令可以參照 gdb tutorial
常用就幾個:b 加斷點,比如上圖中我在test函數加了一個斷點,r是讓程序跑起來,由于test加了斷點,所以在test函數第一行代碼停了下來(顯示的這一行代碼是馬上要執行但是還未執行的),n是單步運行,但是碰到函數并不會進入,s也是單步運行,但是碰到函數可以step into進入到函數內部調試。p經常使用,打印變量的值,有時還使用p/x 將打印的值以16進制顯示。知道這些就夠用,接著看重要的細節:
- p stack+0 打印stack的地址為 0x601080, p stack+1024*1024打印的地址為0x701080,而變量dummy的地址為0x70105f,顯然這個地址正合適在stack+0與stack+1024*1024之間,我們知道局部變量都是在棧上分配的,顯然test函數已經使用stack作為棧來使用了,這也驗證了makecontext制作的上下文環境已經在生效。
2.如果dummy是在棧上分配的,那么下一個變量hello肯定也應該是在棧上分配吧。注意看hello的地址0x701058顯然也是在stack范圍內的。注意為啥hello地址比dummy地址少8呢?畢竟先聲明的是dummy變量,然后才是hello呀?
這里有兩個點:一是少,少是因為現在操作系統的棧增長方向都是像地址減少的方向增加的,這個是目前通用的實現,至于為啥無從考究。也就是說,當我們調用push命令壓棧一個元素,地址是減少的;二是為啥少8,那是因為目前我使用的linux是64位的,棧一個元素占用8個字節,雖然char只有一個字節,但是為了內存對齊(方便硬件讀取 對齊后讀取快),還是會在棧上給char分配8個字節(如果之前學過數字電子線路的同學應該知道內存地址編碼的問題,如果不對齊,硬件會花費多次才能讀出想要的內容,所以現在一般都會提高讀取速度進行內存對齊)。
既然說到棧了,其實棧是一種很有趣的數據結構,太多東西都用到它了,比如JVM對字節碼的執行、Vue源碼中解析模板生成AST、spring解析@Configure、@Import注解、Tomcat對xml的解析(Digester類)等等,太多棧的使用場景,這可不是一句簡單的先進后出可以概括的,里面有很多深刻的內容。而在函數調用這個場景里,操作系統通常會利用棧存儲返回地址、函數參數、局部變量等消息(詳細分析可以參考 函數如何使用棧),我們只看一個比較重要的圖:
我在原圖基礎上加上高低地址的說明,比如main函數里面調用func_A,func_A調用func_B,此時棧的結構就如上圖所示,我們可以發現棧里面有很多重要的東西,返回地址(要不然執行完函數你根本不知道返回到哪兒 有了這個pop一下就拿到了返回地址)、局部變量(Java里面非逃逸對象也是直接在棧上分配喲 減輕GC的壓力)等,圖上還有個棧幀的概念,這個其實是軟件層面的界定,比如main用到的棧的范圍就是main棧幀,其他函數類似,有了這個東西可以對某個函數能夠使用的棧空間進行一定的界定。這不是重點所在,重點就是要知道
1.棧是往地址小的方向增長的(但這不是說棧只往地址小的方向走,而是說用到的時候往小的地方走,函數調用完了,這個函數對應的棧幀就沒用了,此時把棧指針加上一定的size即可,這時棧指針就往大的方向走啦)
2.局部變量是在棧上分配的
ok 要看懂coroutine源碼所需要的知識都說完了,上源碼吧,先看看調度器以及協程(調度器負責協程切換的上下文保護工作)的結構定義:
coroutine.c:
struct schedule {
char stack[STACK_SIZE]; //棧空間
ucontext_t main; //存儲主上下文
int nco; //存儲目前開了多少個協程
int cap;//容量 如果不夠了 會動態擴容 下面有實現
int running;//目前調度器正在運行著的協程的id
struct coroutine **co;//指向一個指針數組其中數組每個元素又指向一個協程的信息
//(協程信息看下面這個結構體)
};
struct coroutine {
coroutine_func func; //協程要執行的函數體
void *ud;//
ucontext_t ctx;//存儲當前協程所對應的上下文信息
struct schedule * sch;//指向調度器 見上面的結構體 每個協程總得知道是誰在調度自己吧
ptrdiff_t cap;//自己的棧的容量
ptrdiff_t size;//自己的棧的真實使用size
int status;//目前協程的狀態 源碼中有下面四種狀態
char *stack;//當前協程所使用的棧的指針
};
coroutine.h:
//協程的所有可能狀態
#define COROUTINE_DEAD 0
#define COROUTINE_READY 1
#define COROUTINE_RUNNING 2
#define COROUTINE_SUSPEND 3
說一些細節:
1 schedule結構體中的棧信息是實打實的一個數組,但是coroutine里的棧卻是棧指針,為啥這樣呢?這就是coroutine的奧妙之處,每個協程都使用調度器里面的空間作為其棧信息 ,然后調度到其他協程時,再為此協程分配內存,將調度器的棧的信息拷貝自己的的stack指針下保存起來,下次再調度自己時,再把stack指針的內容原樣拷貝到調度器的stack數組里面,達到圓潤切換的目的。
- 可以看到struct coroutine里面有個ptrdiff_t的類型,這是個Linux系統提供的類型,意思是存儲指針做減法后的數據類型,因為這兩個字段是通過兩個指針做減法算出來的,后面可以明白這里的精巧用心。
接著讓我們看看初始化調度器的代碼:
coroutine.c:
#define DEFAULT_COROUTINE 16
struct schedule *
coroutine_open(void) {
struct schedule *S = malloc(sizeof(*S));
S->nco = 0;
S->cap = DEFAULT_COROUTINE;
S->running = -1;
S->co = malloc(sizeof(struct coroutine *) * S->cap);
memset(S->co, 0, sizeof(struct coroutine *) * S->cap);
return S;
}
這里代碼邏輯比較簡單,利用malloc為調度器分配空間,然后賦上初值,然后為調度器里面的協程數組先分配默認16個空間,可以看到cap為16,nco為0。這就是一種預分配策略,就像Java里面的ArrayList似的,先初始化一定大小,然后等數據超過擦破大小,再動態擴容。coroutine里面動態擴容的邏輯在:
coroutine.c:
int
coroutine_new(struct schedule *S, coroutine_func func, void *ud) {
struct coroutine *co = _co_new(S, func , ud);
if (S->nco >= S->cap) {
int id = S->cap;
S->co = realloc(S->co, S->cap * 2 * sizeof(struct coroutine *));
memset(S->co + S->cap , 0 , sizeof(struct coroutine *) * S->cap);
S->co[S->cap] = co;
S->cap *= 2;
++S->nco;
return id;
} else {
int i;
for (i=0;i<S->cap;i++) {
int id = (i+S->nco) % S->cap;
if (S->co[id] == NULL) {
S->co[id] = co;
++S->nco;
return id;
}
}
}
assert(0);
return -1;
}
這個函數的作用是為調度器生成新的協程,可以看到if的判斷,當協程的數量要超過容量cap時,用了一個系統函數realloc重新再申請一塊更大的內存(申請的大小是之前cap的兩倍)。
可以順路看一下Java ArrayList的擴容玩法:
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
新的容量=老的容量+老的容量>>1, 右移相當于除以2,新容量大約是老容量的1.5倍。
ok,到了coroutine最精彩的一部分啦:
coroutine.c:
//此函數的作用是 讓調度器運行協程號為id的協程的運行
void
coroutine_resume(struct schedule * S, int id) {
assert(S->running == -1);
assert(id >=0 && id < S->cap);
struct coroutine *C = S->co[id];//通過協程id拿到協程結構體信息 這個id是建立協程時返回的
if (C == NULL)
return;
int status = C->status;
switch(status) {
/**有兩種狀態時可以讓協程恢復運行 一是剛建立的時候 這時線程狀態是READY
但是由于剛建立 棧信息還沒有配置好 所以利用getcontext以及makecontext為其配置上下文**/
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;
/**還有一種就是之前運行過了,然后被調度器調走了,運行別的協程了,
再次回來運行此協程時 狀態變為SUSPEND 這個時候因為剛建立時棧信息已經配置好了 所以這里不需要makecontext配置棧信息了
只需要swapcontext切換到此協程就ok**/
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);
}
}
關鍵就是swapcontext進行上下文的切換,當切換到C->ctx時,由于之前為C->ctx配置的函數名是mainfunc,所以一切換就相當于下一步去執行mainfunc啦,同時2代表有兩個參數,然后后面兩個是調度器結構的地址,由于是64位的機器,這里將高低32位分別放到兩個變量里傳給mainfunc(這里如果不太明白繼續看開頭的例子)。
而mainfunc我們可以想見,肯定要去調用協程關聯的那個函數:
coroutine.c:
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;
}
關鍵一步我已經加上注釋,執行完協程肯定要銷毀資源,下面那些都是在將調度器此協程id對應的協程結構體銷毀掉。還有個小細節,這個函數作者前面加了一個static,由于此函數只在本文件(coroutine.c)內部使用,所以作者加了static(有點像private那種feel static修飾的函數只可以在聲明所在的文件內部使用)。
最最精彩的來了,協程既然要切換,肯定要有個方法讓出CPU吧,here it comes:
coroutine.c:
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);//讓出CPU最牛逼的一句 將當前棧信息保存到協程結構體里面的char *stack那個指針里面
C->status = COROUTINE_SUSPEND;//讓出CPU后 狀態自然要是SUSPEND
S->running = -1;
swapcontext(&C->ctx , &S->main);//正式讓出CPU
}
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);
}
上面這個最精彩就是_save_stack里面那個char dummy,這個變量有毛線用呢?
如果之前記得我們的例子,你應該知道這個dummy我們關心不是他的值(這里等于1 、2、3....100 whatever 不重要),重要的是這個dummy的地址,這個地址在哪兒呀? 在棧上,而別忘了棧是朝地址小的方向增長的!!!那么top一定比dummy的地址大(因為棧是朝地址小的方向增長的),那么代碼中top-&dummy對應一定是啥呀?那一定是這個協程執行過程中產生的棧信息(肯定不能丟呀 丟了這個協程再次回過頭來懵逼了),所以下面利用memcpy函數將&dummy地址拷貝數據到C->stack里面(拷貝大小自然是top- &dummy 看得出這里才對協程中的char *stack進行內存分配 用時拷貝 一開始你也不知道要分配多少合適呀 同時這里也解釋了為啥struct coroutine里面的cap和size變量是ptrdiff_t的類型 因為這兩個字段都是靠指針做減法得到的)。這里的dummy變量真是天馬行空,想象力奇特的一種寫法,小弟敬佩!
既然調度器里面棧信息已經拷貝到協程結構里面啦,那其他協程執行時是不是可以隨意搞了,反正影響不到此協程了呀,真心是棒棒噠!
最后,看看作者在main函數里面給的例子吧:
main.c:
#include "coroutine.h"
#include <stdio.h>
struct args {
int n;
};
static void
foo(struct schedule * S, void *ud) {
struct args * arg = ud;
int start = arg->n;
int i;
for (i=0;i<5;i++) {
printf("coroutine %d : %d\n",coroutine_running(S) , start + i);
coroutine_yield(S);
}
}
static void
test(struct schedule *S) {
struct args arg1 = { 0 };
struct args arg2 = { 100 };
int co1 = coroutine_new(S, foo, &arg1);
int co2 = coroutine_new(S, foo, &arg2);
printf("main start\n");
while (coroutine_status(S,co1) && coroutine_status(S,co2)) {
coroutine_resume(S,co1);
coroutine_resume(S,co2);
}
printf("main end\n");
}
int
main() {
struct schedule * S = coroutine_open();
test(S);
coroutine_close(S);
return 0;
}
上圖中的foo函數相當于業務代碼,需要我們自己寫,這里循環一次讓出一次CPU,所以代碼執行結果如下:
main start
coroutine 0 : 0
coroutine 1 : 100
coroutine 0 : 1
coroutine 1 : 101
coroutine 0 : 2
coroutine 1 : 102
coroutine 0 : 3
coroutine 1 : 103
coroutine 0 : 4
coroutine 1 : 104
main end
有沒有一種多線程運行結果的既視感? 但是可以看出代碼中既沒有使用fork函數(多進程)也沒有使用pthread函數(多線程),操作系統壓根沒有感覺到你在切換(我們根本沒有進入內核態),我們在用戶態就實現了這種切換的feel。真心是棒棒噠!
還有幾個小函數沒有講到,因為那幾個真心太簡單了,有的一兩行,都是為上面的核心流程服務的,理解了核心流程,那幾個函數自然而然的搞懂。
當然這個版本畢竟比較簡單,還只能做到協程自己主動讓出CPU,但是這么雷鋒的協程畢竟很少,其實更多時候是碰到阻塞操作了(比如IO操作),需要讓出CPU,那就是要涉及IO多路復用啦,云風大神只是給我們打個樣,讓我們理解這個版本之后往更復雜的版本邁進!
先寫到此吧,繼續擼libtask和libgo的代碼啦!有感再撰文!
I love coroutine!