如今雖不敢說協(xié)程已經(jīng)是紅的發(fā)紫,但確實是越來越受到了大家的重視。Golang中的已經(jīng)是只有g(shù)oroutine,以至于很多go程序員是只知有協(xié)程,不知有線程了。就連C++也在最新的C++20中原生支持協(xié)程。更不用說很多活躍的語言如python,java等,也都是支持協(xié)程的。盡管這些協(xié)程可能名稱不同,甚至用法也不同,但它們都可以被劃分為兩大類,一類是有(stackful) 協(xié)程,例如 goroutine,libco;一類是無棧 (stackless) 協(xié)程,例如C++的協(xié)程。
這里我們想說的一點是所謂的有棧,無棧并不是說這個協(xié)程運行的時候有沒有棧,而是說協(xié)程之間是否存在調(diào)用棧(callbackStack)。其實仔細(xì)一想即可,但凡是個正在運行的程序,不管你是協(xié)程也好,線程也好,怎么可能在運行的時候不使用??臻g呢,調(diào)用參數(shù)往哪擱,局部變量往哪擱。我們知道基本所有的主流語言在調(diào)用另外一個函數(shù)的時候都存在一個調(diào)用棧,我們來解釋一下調(diào)用棧這個詞:
這幅圖是有兩個棧幀的調(diào)用棧,我在這篇文章中對棧幀下過定義,即:函數(shù)的棧幀是指esp和ebp之間的一塊地址。拿上圖來說ebp存儲著Frame Pointer指向的地址,Return Address當(dāng)然就是我們在執(zhí)行完最新的棧幀以后下一步要執(zhí)行的指令地址。esp當(dāng)然就是當(dāng)前指向棧頂?shù)闹羔樍恕?/p>
有棧協(xié)程
很多地方又把協(xié)程稱為subroutine,subroutine是什么,就是函數(shù)。上古時期的計算機科學(xué)家們早就給出了概念,coroutine就是可以中斷并恢復(fù)執(zhí)行的subroutine,從這個角度來看協(xié)程擁有調(diào)用棧并不是一個奇怪的事情。我們再來思考coroutine與subroutinue相比有什么區(qū)別,你會發(fā)現(xiàn)區(qū)別僅有一個,就是coroutinue可以中斷并恢復(fù),對應(yīng)的操作就是yield/resume,這樣看來subroutinue不過是coroutinue的一個子集罷了。也就是說把協(xié)程當(dāng)做一個特殊的函數(shù)調(diào)用,有棧協(xié)程就是我們理想中協(xié)程該有的模樣。
既然把其當(dāng)做一個特殊的函數(shù)調(diào)用,對我們來說最嚴(yán)峻的挑戰(zhàn)就是如何像切換函數(shù)一樣去切換協(xié)程,難點在于除了像函數(shù)一樣切換出去,還要在某種條件滿足的時候切換回來,我們的做法可以是在協(xié)程內(nèi)部存儲自身的上下文,并在需要切換的時候把上下文切換就可以了,我們知道上下文其實本質(zhì)上就是寄存器,所以保存上下文實際上就是把寄存器的值保存下來,有兩種方法,一種是使用匯編,libco就使用了這種方法。還有一種是使用ucontext.h,這個封裝好的庫也可以幫我們完成相關(guān)工作。
匯編的話我們來看一看libco中對于32位機器的上下文切換操作是如何完成的:
// 獲取第一個參數(shù)
movl 4(%esp), %eax
// 參數(shù)的類型我們暫且理解為一個擁有八個指針的數(shù)組,即regs
| regs[7] |
| regs[6] |
| regs[5] |
| regs[4] |
| regs[3] |
| regs[2] |
| regs[1] |
| regs[0] |
-------------- < ---EAX
movl %esp, 28(%eax)
movl %ebp, 24(%eax)
movl %esi, 20(%eax)
movl %edi, 16(%eax)
movl %edx, 12(%eax)
movl %ecx, 8(%eax)
movl %ebx, 4(%eax)
// 想想看,這里eax加偏移不就是對應(yīng)了regs中的值嗎?這樣就把所有寄存器中的值保存在了參數(shù)中
// ESP偏移八位就是第二個參數(shù)的偏移了,這樣我們就可以把第二個參數(shù)regs中的上下文切換到寄存器中了
movl 8(%esp), %eax
movl 4(%eax), %ebx
movl 8(%eax), %ecx
movl 12(%eax), %edx
movl 16(%eax), %edi
movl 20(%eax), %esi
movl 24(%eax), %ebp
movl 28(%eax), %esp
ret
// 這樣我們就完成了一次協(xié)程的切換
我們可以看到其實就是參數(shù)中傳入兩個協(xié)程的上下文結(jié)構(gòu),然后第一個參數(shù)執(zhí)行保存上下文,然后把第二個參數(shù)的上下文存入寄存器,這樣就執(zhí)行了兩個協(xié)程的切換。
當(dāng)然我們上面提到了調(diào)用棧,那么既然有調(diào)用棧,那么肯定有一個執(zhí)行的順序,即一定要把棧頂?shù)膮f(xié)程全部運行完才可以運行下一層的協(xié)程,這樣說可能比較抽象,我們舉一個簡單的例子:
主協(xié)程A中執(zhí)行協(xié)程B,此時調(diào)用棧是在[A,B]和[A]之間切換,因為B會主動讓出執(zhí)行權(quán),然后調(diào)用棧上此時就只有一個A了
B協(xié)程中執(zhí)行C,D協(xié)程,此時調(diào)用棧是在[A,B,C],[A,B],[A,B,D]之間轉(zhuǎn)換的,
這樣看來我們總是只能在調(diào)用棧頂?shù)膮f(xié)程運行完以后才能去執(zhí)行更低一層的協(xié)程,當(dāng)然,這也是典型的非對稱協(xié)程,即協(xié)程之間有明顯的調(diào)用關(guān)系。
當(dāng)然在我的描述中也可以看出有棧協(xié)程涉及到對于寄存器的保存和修改,也涉及到對每一個協(xié)程棧(實際運行的棧)的分配。對于寄存器來說,現(xiàn)代寄存器基本都是上百個字節(jié)的數(shù)據(jù),還有每一個協(xié)程的棧,如果選擇了共享棧,又涉及到對棧上數(shù)據(jù)的拷貝,顯然在效率上來說相比無棧協(xié)程的確是有一些損失的。
無棧協(xié)程
那么所謂的無棧協(xié)程是什么呢?其實無棧協(xié)程的本質(zhì)就是一個狀態(tài)機(state machine),它可以理解為在另一個角度去看問題,即同一協(xié)程協(xié)程的切換本質(zhì)不過是指令指針寄存器的改變。這里推薦一篇文章,其內(nèi)容是用C語言實現(xiàn)一個協(xié)程,其實就是一個無棧協(xié)程的實現(xiàn)。
我們來看一個使用libco的協(xié)程的例子,當(dāng)然libco是一個有棧協(xié)程:
void* test(void* para){
co_enable_hook_sys();
int i = 0;
poll(0, 0, 0. 1000); // 協(xié)程切換執(zhí)行權(quán),1000ms后返回
i++;
poll(0, 0, 0. 1000); // 協(xié)程切換執(zhí)行權(quán),1000ms后返回
i--;
return 0;
}
int main(){
stCoRoutine_t* routine;
co_create(&routine, NULL, test, 0);// 創(chuàng)建一個協(xié)程
co_resume(routine);
co_eventloop( co_get_epoll_ct(),0,0 );
return 0;
}
這段代碼實際的意義就是主協(xié)程跑一個協(xié)程去執(zhí)行test函數(shù),在test中我們需要兩次從協(xié)程中切換出去,這里對應(yīng)了兩個poll操作(hook機制,有興趣的朋友可以點擊這里),hook后的poll所做的事情就是把當(dāng)前協(xié)程的CPU執(zhí)行權(quán)切換到調(diào)用棧的上一層,并在超時或注冊的fd就緒時返回(當(dāng)然樣例這里就只是超時了)。那么無棧協(xié)程跑相同的代碼是怎么樣的呢?其實就是翻譯成類似于以下代碼:
struct test_coroutine {
int i;
int __state = 0;
void MoveNext() {
switch(__state) {
case 0:
return frist();
case 1:
return second();
case 2:
return third();
}
}
void frist() {
i = 0;
__state = 1;
}
void second() {
i++;
_state = 2;
}
void third() {
i--;
}
};
我們可以看到相比與有棧協(xié)程中的test函數(shù),這里把整個協(xié)程抽象成一個類,以原本需要執(zhí)行切換的語句處為界限,把函數(shù)劃分為幾個部分,并在某一個部分執(zhí)行完以后進行狀態(tài)轉(zhuǎn)移,在下一次調(diào)用此函數(shù)的時候就會執(zhí)行下一部分,這樣的話我們就完全沒有必要像有棧協(xié)程那樣顯式的執(zhí)行上下文切換了,我們只需要一個簡易的調(diào)度器來調(diào)度這些函數(shù)即可。
從執(zhí)行時棧的角度來看,其實所有的協(xié)程共用的都是一個棧,即系統(tǒng)棧,也就也不必我們自行去給協(xié)程分配棧,因為是函數(shù)調(diào)用,我們當(dāng)然也不必去顯示的保存寄存器的值,而且相比有棧協(xié)程把局部變量放在新開的空間上,無棧協(xié)程直接使用系統(tǒng)棧使得CPU cache局部性更好,同時也使得無棧協(xié)程的中斷和函數(shù)返回幾乎沒有區(qū)別,這樣也可以凸顯出無棧協(xié)程的高效。
對稱協(xié)程與非對稱協(xié)程
其實對于“對稱”這個名詞,闡述的實際是協(xié)程之間的關(guān)系,用大白話來說就是對稱協(xié)程就是說協(xié)程之間人人平等,沒有誰調(diào)用誰一說,大家都是一樣的,而非對稱協(xié)程就是協(xié)程之間存在明顯的調(diào)用關(guān)系。
簡單來說就是這樣:
- 對稱協(xié)程 Symmetric Coroutine:任何一個協(xié)程都是相互獨立且平等的,調(diào)度權(quán)可以在任意協(xié)程之間轉(zhuǎn)移。
- 非對稱協(xié)程 Asymmetric Coroutine:協(xié)程出讓調(diào)度權(quán)的目標(biāo)只能是它的調(diào)用者,即協(xié)程之間存在調(diào)用和被調(diào)用關(guān)系。
其實兩者的實現(xiàn)我覺得其實差異不大,非對稱協(xié)程其實就是擁有調(diào)用棧,而非對稱協(xié)程則是大家都平等,不需要調(diào)用棧,只需要一個數(shù)據(jù)結(jié)構(gòu)存儲所有未執(zhí)行完的協(xié)程即可。至于哪種更優(yōu)?我覺得分情況,如果你使用協(xié)程的目的是為了優(yōu)化一些IO密集型應(yīng)用,那么協(xié)程切換出去的時候就是它等待事件到來的時候,此時你就算切換過去也沒有什么意義,還不如等到事件到來的時候自動切換回去。
其實上面說的是有一些問題,因為這個執(zhí)行權(quán)的切換實際上是(調(diào)用者–被調(diào)用者)之間的切換,對稱就是它們之間都是平等的,就是假如A協(xié)程執(zhí)行了B,C協(xié)程,那么B協(xié)程可以切換回A,也可以切換回C。而非對稱只能是B切換回A,A切換回C,C再切換回A,以此類推。
這樣看起來顯然非對稱協(xié)程相比之下更為符合我們的認(rèn)知,因為對稱協(xié)程目前我不知道如何選擇一個合適的協(xié)程來獲得CPU執(zhí)行權(quán),正如上面所說,此協(xié)程可能正在等待事件。當(dāng)然如果調(diào)度算法足夠優(yōu)秀的話,對稱協(xié)程也是可取的。
-
cpu
+關(guān)注
關(guān)注
68文章
10863瀏覽量
211797 -
程序
+關(guān)注
關(guān)注
117文章
3787瀏覽量
81060 -
函數(shù)
+關(guān)注
關(guān)注
3文章
4331瀏覽量
62630 -
C++
+關(guān)注
關(guān)注
22文章
2108瀏覽量
73657
發(fā)布評論請先 登錄
相關(guān)推薦
評論