李樂html
提起協程,你可能會說,不就go func嗎,我分分鐘就能建立上萬個協程。但是協程究竟是什麼呢?都說協程是用戶態線程,這裏的用戶態是什麼意思?都說協程比線程更輕量,協程輕量在哪裏呢?linux
本文主要爲讀者介紹這些內容:git
經過本篇文章,你將從根本上了解Golang協程。github
注:爲何選擇v1.0版本呢?由於他足夠的簡單,不過,麻雀雖小五臟俱全;並且你會發現,即便到了如今,Golang協程實現原理,也就那麼回事。v1.0版本代碼能夠從github上下載,分支爲release-branch.go1。編程
在講解Golang協程實現以前,還須要補充一些基礎知識。理解協程,就須要理解函數棧幀,以及虛擬內存。而函數棧幀的管理,須要從彙編層次去解讀。數組
PS:不要怕,彙編其實很簡單,不過幾條指令,幾個寄存器而已。網絡
linux將內存組織爲一些區域(段)的集合,如代碼段,數據段,運行時堆,共享庫段,以及用戶棧都是不一樣的區域。以下圖所示:數據結構
用戶棧,自上而下增加,寄存器%rsp指向用戶棧的棧頂位置;經過malloc分配的內存一般是在運行時堆。多線程
想一想函數調用過程,好比func1調用func2,待func2執行完畢後,還會迴歸道func1繼續執行。該過程很是相似於棧結構,先入後出。大多數語言的函數調用都採用棧結構實現(基於用戶棧),函數的調用與返回即對應一系列的入棧與出棧操做,而咱們日常遇到的棧溢出就是由於函數調用層級過深,不斷入棧致使的。函數棧幀以下圖所示:架構
寄存器%rbp指向函數棧幀底部位置,寄存器%rsp指向函數棧幀頂部位置。能夠看到,在函數棧幀入棧時候,還會將調用方函數棧幀的%rbp寄存器入棧,以及實現多個函數棧幀的連接關係。不然,當前函數執行完畢後,如何恢復其調用方的函數棧幀?
誰爲我維護着函數棧幀結構呢?固然是個人代碼了,但是我都沒關注過這些啊。能夠看看編譯器生成的彙編代碼,咱們簡單寫一個c程序:
int add(int x, int y) { return x+y; } int main() { int sum = add(111,222); }
查看編譯結果:
main: pushq %rbp movq %rsp, %rbp subq $16, %rsp movl $222, %esi movl $111, %edi call add movl %eax, -4(%rbp) leave ret add: pushq %rbp movq %rsp, %rbp movl %edi, -4(%rbp) movl %esi, -8(%rbp) movl -8(%rbp), %eax movl -4(%rbp), %edx addl %edx, %eax popq %rbp ret
能夠看到main以及add函數入口,都對應有修改%rbp以及%rsp指令。
另外,讀者請注意:這個示例,函數調用過程當中,參數的傳遞以及返回值是經過寄存器傳遞的,好比第一個參數是%edi,第二個參數是%esi,返回值是%eax。參數以及返回值如何傳遞,其實並非那麼重要,約定好便可,好比Golang語言,參數以及返回值都是基於棧幀傳遞的。
任何架構的計算機都會提供一組指令集合,彙編是二進制指令的文本形式。指令由操做碼和操做數組成;操做碼即操做類型,操做數能夠是一個當即數或者一個存儲地址(內存,寄存器)。寄存器是集成在CPU內部,訪問很是快,可是數量有限的存儲單元。Golang使用plan9彙編語法,彙編指令的寫法以及寄存器的命名略有不一樣
下面簡單介紹一些經常使用的指令以及寄存器:
更多plan9知識參考:https://xargin.com/plan9-asse...
下面寫一個go程序,看看編譯後的彙編代碼:
package main func addSub(a, b int) (int, int){ return a + b , a - b } func main() { addSub(333, 222) }
彙編代碼查看:go tool compile -S -N -l test.go
"".addSub STEXT nosplit size=49 args=0x20 locals=0x0 0x0000 00000 (test.go:3) MOVQ $0, "".~r2+24(SP) 0x0009 00009 (test.go:3) MOVQ $0, "".~r3+32(SP) 0x0012 00018 (test.go:4) MOVQ "".a+8(SP), AX 0x0017 00023 (test.go:4) ADDQ "".b+16(SP), AX 0x001c 00028 (test.go:4) MOVQ AX, "".~r2+24(SP) 0x0021 00033 (test.go:4) MOVQ "".a+8(SP), AX 0x0026 00038 (test.go:4) SUBQ "".b+16(SP), AX 0x002b 00043 (test.go:4) MOVQ AX, "".~r3+32(SP) 0x0030 00048 (test.go:4) RET "".main STEXT size=68 args=0x0 locals=0x28 0x000f 00015 (test.go:7) SUBQ $40, SP 0x0013 00019 (test.go:7) MOVQ BP, 32(SP) 0x0018 00024 (test.go:7) LEAQ 32(SP), BP 0x001d 00029 (test.go:8) MOVQ $333, (SP) 0x0025 00037 (test.go:8) MOVQ $222, 8(SP) 0x002e 00046 (test.go:8) CALL "".addSub(SB) 0x0033 00051 (test.go:9) MOVQ 32(SP), BP 0x0038 00056 (test.go:9) ADDQ $40, SP 0x003c 00060 (test.go:9) RET
分析main函數彙編代碼:SUBQ $40, SP爲本身分配棧幀區域,LEAQ 32(SP), BP,移動BP寄存器到本身棧幀結構的底部。MOVQ $333, (SP)以及MOVQ $222, 8(SP)在準備輸入參數。
分析addSub函數彙編代碼:"".a+8(SP)即輸入參數a,"".b+16(SP)即輸入參數b。兩個返回值分別在24(SP)以及32(SP)。
注意:addSub函數,並無經過SUBQ $xx, SP以,來爲本身分配棧幀區域。由於addSub函數沒有再調用其餘函數,也就沒有必要在爲本身分配函數棧幀區域了。
另外,注意main函數,addSub函數,是如何傳遞與引用輸入參數以及返回值的。
線程本地存儲(Thread Local Storage,簡稱TLS),其實就是線程私有全局變量。普通的全局變量,一個線程對其進行了修改,全部線程均可以看到這個修改;線程私有全局變量不一樣,每一個線程都有本身的一份副本,某個線程對其所作的修改不會影響到其它線程的副本。
Golang是多線程程序,當前線程正在執行的協程,顯然每一個線程都是不一樣的,這就維護在線程本地存儲。因此在Golang協程切換邏輯中,隨處可見『get_tls(CX)』,用於獲取當前線程本地存儲首地址。
不一樣的架構以及操做系統,能夠經過FS或者GS寄存器訪問線程本地存儲,如Golang程序,383架構Linux操做系統時,經過以下方式訪問:
//"386", "linux" "#define get_tls(r) MOVL 8(GS), r\n" //獲取線程本地存儲首地址 get_tls(CX) //結構體G封裝協程相關數據,DX存儲着當前正在執行協程G的首地址 //協程調度時,保存當前協程G到線程本地存儲 MOVQ DX, g(CX)
線程本地存儲簡單瞭解下就行,更多知識可參考文章:https://www.cnblogs.com/abozh...
不少人對Golang併發模型MPG應該是比較瞭解的,以下圖所示。其中,G表明一個協程;M表明一個工做線程;P表明邏輯處理器,其維護着可運行協程的隊列runq;須要注意的是,M只有和P綁定後,才能調度並執行協程。另外,g0是一個特殊的協程,用於執行調度邏輯,以及協程建立銷燬等邏輯。
Golang v1.0版本併發模型仍是比較簡單的,這時候尚未邏輯處理器P,只有MG,以下圖所示。注意這時候可運行協程隊列維護在全局,所以每次調度都須要加鎖,性能是比較低的。
有幾個重要的結構體咱們須要簡單瞭解下,好比M,G,以及協程調度相關Gobuf。
結構體M封裝線程相關數據,字段較多,可是目前基本均可以不關注。結構體G封裝協程相關數據,咱們先了解這幾個字段:
struct G { //協程ID int32 goid; //協程入口函數 byte* entry; // initial function //協程棧 byte* stack0; //協程調度相關 Gobuf sched; //協程狀態 int16 status; }
注意Gobuf結構,其定義了協程調度相關的上下文數據:
struct Gobuf { //寄存器SP byte* sp; //寄存器PC byte* pc; //執行協程對象 G* g; };
Golang定義協程有下面幾種狀態:
enum { //協程建立初始狀態 Gidle, //協程在可運行隊列等待調度 Grunnable, //協程正在被調度運行 Grunning, //協程正在執行系統調用 Gsyscall, //協程處於阻塞狀態,沒有在可運行隊列 Gwaiting, //協程執行結束,等待調度器回收 Gmoribund, //協程已被回收 Gdead, };
協程狀態轉移以下圖所示:
經過go關鍵字能夠很方便的建立協程,Golang編譯器會將go關鍵字替換爲runtime.newproc函數調用,函數newproc實現了協程的建立邏輯,定義以下:
//siz:參數數目;fn:入口函數 func newproc(siz int32, fn *funcval);
在講解協程建立以前,咱們先思考下,須要建立什麼?僅僅是一個結構體G嗎?
咱們回顧一下函數調用過程,func1調用func2,func2函數棧幀入棧,func2執行完畢,func2函數棧幀出棧,從新回到func1的函數棧幀。那若是func1以及func2表明着兩個協程呢?這兩個函數會並行執行,還能像函數調用過程同樣嗎?顯然是不行的,由於func1以及func2函數棧幀須要隨意切換。
咱們能夠類比下線程,多線程程序,每個線程都有一個用戶棧(參考虛擬內存結構,存在多個用戶棧),該用戶棧由操做系統維護(建立,切換,回收)。線程執行爲何須要用戶棧呢?函數的局部變量,函數調用過程的入參傳遞,返回值傳遞,都是基於用戶棧實現的。
協程也須要多個用戶棧,只不過這些用戶棧須要Golang來維護。咱們能經過系統調用建立用戶棧嗎?顯然是不能的。可是,咱們上面提到過,寄存器%rsp以及寄存器%rbp指向了用戶棧,CPU知道什麼是棧什麼是堆嗎?不知道,他只須要基於寄存器%rsp入棧以及出棧就好了。正式基於此,咱們能夠偷樑換柱,在堆上申請一塊內存,將寄存器%rsp以及寄存器%rbp指過去,從而將這塊內存假裝成用戶棧。
協程建立主要邏輯由函數runtime·newproc1實現,主要步驟有:1)從空閒鏈表中獲取結構體G;2)若是沒有獲取到空閒的G,則從新分配,包括分配結構體G以及協程棧;3)將建立好的協程加入到可運行隊列。
//fn:協程入口函數;argp:參數首地址;narg:輸入參數所佔字節數;nret:返回值所佔字節數;callerpc:調用方PC指針 G* runtime·newproc1(byte *fn, byte *argp, int32 narg, int32 nret, void *callerpc) { //根據參數數目,以及返回值數目;計算棧所需空間 siz = narg + nret; //加鎖;會操做全局數據 schedlock(); //從全局鏈表獲取Gruntime·sched.gfree if((newg = gfget()) != nil){ }{ //申請G,以及協程棧 newg = runtime·malg(StackMin); } //協程棧頂指針(棧自頂向下) sp = newg->stackbase; sp -= siz; //初始化:協程狀態,協程棧頂指針sp,協程退出處理函數pc,協程入口函數entry newg->status = Gwaiting; newg->sched.sp = sp; newg->sched.pc = (byte*)runtime·goexit; newg->sched.g = newg; newg->entry = fn; //協程數目統計 runtime·sched.gcount++; //自增協程ID runtime·sched.goidgen++; newg->goid = runtime·sched.goidgen; //將協程加入到可運行隊列 newprocreadylocked(newg); //釋放鎖 schedunlock(); return newg; }
這裏讀者需重點關注兩處邏輯:1)runtime·malg申請協程棧空間,注意棧空間申請邏輯只能在g0棧執行,g0棧就是協程g0的棧,因此這裏可能還存在棧的切換,下一個小節將詳細介紹;2)初始化協程時候,注意協程棧頂指針sp,協程退出處理函數pc,協程入口函數entry,後面協程切換時候很是重要。
咱們以前說過,g0是一個特殊的協程,用於執行調度邏輯,以及協程建立銷燬等邏輯。這句話仍是比較抽象的,可能仍是不明白g0協程究竟是什麼?其實只要記住一句話:程序邏輯的執行都須要棧空間。所以須要把調度邏輯,以及協程建立銷燬等邏輯在獨立的棧空間(g0棧)上執行。
因此隨處可見這樣的邏輯:
//協程棧申請,必須在g0棧 if(g == m->g0) { // running on scheduler stack already. stk = runtime·stackalloc(StackSystem + stacksize); } else { //runtime·mcall專門用於切換棧幀到g0 runtime·mcall(mstackalloc); } //協程調度,必須在g0棧 void runtime·gosched(void) { runtime·mcall(schedule); }
runtime·mcall函數聲明以下,其中fn就是切換到g0棧去執行的函數,如調度邏輯,棧幀分配邏輯:
void mcall(void (*fn)(G*))
下面就只能硬着頭皮看了,不去一行一行看runtime·mcall的彙編實現,永遠沒法真正理解協程棧切換的本質。
TEXT runtime·mcall(SB), 7, $0 //FP僞寄存器,fn+0(FP)方式可得到第一個參數fn,存儲到寄存器DI MOVQ fn+0(FP), DI //線程本地存儲,能夠獲取當前執行協程g,以及當前線程m get_tls(CX) //寄存器CX指向線程本地存儲,g(CX)可獲取當前執行協程,存儲在寄存器AX MOVQ g(CX), AX //基於指令CALL調用函數runtime·mcall時候,會入棧指令寄存器PC; //0(SP)即調用方下一條待執行指令 MOVQ 0(SP), BX //g_sched即sched字段在結構體g偏移量;gobuf_pc即pc字段在結構體gobuf偏移量; //g_sched以及gobuf_pc都是宏定義,而且由腳本生成 //保存當前協程上下文:下一條待執行指令 MOVQ BX, (g_sched+gobuf_pc)(AX) //調用方棧頂位置,在8(SP);參考函數棧幀示意圖 LEAQ 8(SP), BX MOVQ BX, (g_sched+gobuf_sp)(AX) //AX存儲着當前協程 MOVQ AX, (g_sched+gobuf_g)(AX) // AX爲當前協程,m->g0爲g0協程;判斷當前協程是不是g0協程 MOVQ m(CX), BX MOVQ m_g0(BX), SI CMPQ SI, AX // if g == m->g0 call badmcall JNE 2(PC) //若是是,非法的runtime·mcall調用 CALL runtime·badmcall(SB) //SI爲g0協程,g(CX)協程本地存儲,賦值當前執行協程爲g0 //(程序不少地方都須要判斷當前執行的是哪一個協程,因此切換前須要更新) MOVQ SI, g(CX) // g = m->g0 //SI爲g0協程,恢復g0協程上下文:sp寄存器 MOVQ (g_sched+gobuf_sp)(SI), SP // sp = m->g0->gobuf.sp //注意函數聲明,void (*fn)(G*),輸入參數爲G。 //AX爲即將換出的協程,這裏將輸入參數入棧 PUSHQ AX //DI即第一個參數fn,調用該函數 CALL DI POPQ AX //由於fn理論上是死循環,永遠不會執行結束;若是到這裏說明出異常了 CALL runtime·badmcall2(SB) RET
每一行彙編的含義都有註釋,這裏就再也不一一介紹。
經過runtime·mcall彙編實現,讀者能夠看到,協程切換,切換的就是指令寄存器PC,以及棧寄存器SP。重點關注當前協程下一條指令,以及協程棧頂指針獲取方式。
須要特別注意的是:其輸入參數fn永遠不會返回,該函數會切換到其餘協程執行。一旦fn執行返回了,會調用runtime·badmcall2拋異常(panic)。
最後能夠思考下,m->g0即當前線程的g0協程,變量g即當前正在執行的協程,因此代碼裏纔有這樣的邏輯:
extern register G* g; if(g == m->g0) { }
然而又發現,變量g定義的是全局寄存器變量,改變量理論上應該在協程切換時更新。但是協程切換時,確實沒有更新的邏輯,只能找到更新線程本地存儲的邏輯。其實這是由於Golang編譯器作了一個改動,將extern register變量與協程本地存儲關聯起來了。
// on 386 or amd64, "extern register" generates // memory references relative to the // gs or fs segment.
咱們再回顧下協程建立過程當中申請棧幀的邏輯:
G* runtime·malg(int32 stacksize) { if(g == m->g0) { // running on scheduler stack already. stk = runtime·stackalloc(StackSystem + stacksize); } else { // have to call stackalloc on scheduler stack. g->param = (void*)(StackSystem + stacksize); runtime·mcall(mstackalloc); stk = g->param; } newg->stack0 = stk; } static void mstackalloc(G *gp) { gp->param = runtime·stackalloc((uintptr)gp->param); runtime·gogo(&gp->sched, 0); }
假設在協程A中,經過go關鍵字建立協程B;g->param變量即須要申請的協程棧大小。觀察函數mstackalloc聲明,該函數在g0棧上執行,其第一個參數gp指向協程A;棧空間申請完畢後,又經過runtime·gogo切換回協程,繼續協程B的初始化。整個過程以下圖所示:
協程建立,協程結束,協程由於某些緣由阻塞,可能都會觸發協程的切換。
如上一節介紹的函數runtime·gogo,就實現了協程切換功能。
//gobuf:待執行協程上下文結構; void runtime·gogo(Gobuf*, uintptr); TEXT runtime·gogo(SB), 7, $0 MOVQ 16(SP), AX // return 2nd arg //第一個參數gobuf,存儲在寄存器BX MOVQ 8(SP), BX // gobuf //gobuf_g即字段g相對於gobuf的偏移量;協程g存儲在DX MOVQ gobuf_g(BX), DX MOVQ 0(DX), CX // make sure g != nil //獲取線程本地存儲 get_tls(CX) //即將執行的協程,保存在線程本地存儲 MOVQ DX, g(CX) //恢復協程上下文:棧頂寄存器SP MOVQ gobuf_sp(BX), SP // restore SP //恢復協程上下文:下一條指令 MOVQ gobuf_pc(BX), BX //指令跳轉,這就切換到新的協程了 JMP BX
首次切換到協程時候,並非經過runtime·gogo實現的。而是基於runtime·gogocall,爲何要區分呢?由於首次切換到協程,還有一些特殊任務須要處理,如提早設置好協程結束處理函數。
//gobuf:待執行協程上下文結構;第二個參數:協程入口函數 void runtime·gogocall(Gobuf*, void(*)(void)); static void schedule(G *gp) { //協程上下文PC等於runtime·goexit,說明協程尚未開始執行過 if(gp->sched.pc == (byte*)runtime·goexit) { runtime·gogocall(&gp->sched, (void(*)(void))gp->entry); } } TEXT runtime·gogocall(SB), 7, $0 //第二個參數:協程入口函數 MOVQ 16(SP), AX // fn //第一個參數,gobuf MOVQ 8(SP), BX // gobuf //待執行協程g,保存在寄存器DX MOVQ gobuf_g(BX), DX //獲取線程本地存儲 get_tls(CX) //即將執行的協程,保存在線程本地存儲 MOVQ DX, g(CX) MOVQ 0(DX), CX // make sure g != nil //恢復協程上下文:棧頂寄存器SP MOVQ gobuf_sp(BX), SP // restore SP //此時,gobuf_pc等於runtime·goexit,存儲在寄存器BX MOVQ gobuf_pc(BX), BX //思考下爲何BX要入棧? PUSHQ BX //指令跳轉,AX爲協程入口函數 JMP AX POPQ BX // not reached
runtime·gogocall以及runtime·gogo函數實現了協程的換入工做;另外,協程換出時候,經過runtime·gosave保存協程上下文,該函數在協程即將進入系統調用時候執行。
//gobuf:協程上下文結構 void gosave(Gobuf*) TEXT runtime·gosave(SB), 7, $0 //第一個參數gobuf MOVQ 8(SP), AX // gobuf //調用方的棧頂位置存儲在8(SP),參考函數棧幀示意圖 LEAQ 8(SP), BX // caller's SP //協程上下文:棧頂位置保存在gobuf->sp MOVQ BX, gobuf_sp(AX) //協程上下文:下一條待執行指令保存在在gobuf->pc MOVQ 0(SP), BX // caller's PC MOVQ BX, gobuf_pc(AX) //獲取線程本地存儲 get_tls(CX) MOVQ g(CX), BX //當前協程g,存儲在gobuf->g MOVQ BX, gobuf_g(AX) RET
經過上面三個函數的彙編實現,相信讀者對協程切換:上下文保存以及上下文恢復,都有了必定了解。
想象下,若是某協程的處理函數爲funcA,funcA執行完畢,至關於該協程的結束。這以後該怎麼辦?確定須要執行特定的回收工做。注意到上面小節有一個函數,runtime·goexit,看名字協程結束時候應該執行這個函數。如何在funcA執行完畢後,調用runtime·goexit呢?
再次回顧函數調用過程,以及函數棧幀示意圖。函數funcA執行完畢時候,存在一個RET指令,該指令會彈出下一條待指令到指令寄存器PC,從而只限指令的跳轉。咱們再觀察runtime·gogocall的實現邏輯,有這麼一行指令:
TEXT runtime·gogocall(SB), 7, $0 //BX即gobuf->pc,初始爲runtime·goexit PUSHQ BX //指令跳轉,AX爲協程入口函數 JMP AX POPQ BX // not reached
邏輯串起來了,PUSHQ BX,將函數runtime·goexit首地址入棧,所以協程執行結束後,RET彈出的指令就是函數runtime·goexit首地址,從而開始了協程回收工做。而函數runtime·goexit,則標記協程狀態爲Gmoribund,開始新一次的協程調度(會切換到g0調度)
void runtime·goexit(void) { g->status = Gmoribund; runtime·gosched(); }
調度器負責維護協程狀態,獲取一個可運行協程並執行。調度邏輯主要在函數schedule中,正如上面所說,調度邏輯確定須要運行在g0棧,所以一般這麼執行調度函數:
runtime·mcall(schedule);
調度函數的聲明以下,輸入參數gp是什麼呢?固然是即將換出的協程,參數的準備可在runtime·mcall彙編中看到:
static void schedule(G *gp) TEXT runtime·mcall(SB), 7, $0 //注意函數聲明,void (*fn)(G*),輸入參數爲G。 //AX爲即將換出的協程,這裏將輸入參數入棧 PUSHQ AX //DI即第一個參數fn,調用該函數 CALL DI
切換到調度器後,會更新協程狀態,接着從可運行隊列獲取一個新的協程去執行:
static void schedule(G *gp) { if(gp != nil) { switch(gp->status){ case Grunning: //放入可運行列表 gp->status = Grunnable; gput(gp); break; case Gmoribund: //協程結束;回收到空閒列表,可重複利用 gp->status = Gdead; gfput(gp); //省略 } } // Find (or wait for) g to run. //獲取可運行協程 gp = nextgandunlock(); gp->status = Grunning; //運行協程 if(gp->sched.pc == (byte*)runtime·goexit) { runtime·gogocall(&gp->sched, (void(*)(void))gp->entry); } runtime·gogo(&gp->sched, 0); }
進一步,在調度函數schedule之上又封裝了runtime·gosched,在觸發協程調度時候,一般基於該函數完成。能夠簡單畫下協程調度示意圖:
有不少種狀況可能會觸發協程調度:好比讀寫管道阻塞了,好比socket操做等等,下面將分別介紹。
管道一般用於協程間的數據交互,管道的結構體定義以下:
struct Hchan { //已寫入管道的數據總量 uint32 qcount; // total data in the q //管道最大數據量 uint32 dataqsiz; // size of the circular q //讀管道阻塞的協程,是一個隊列 WaitQ recvq; // list of recv waiters //寫管道阻塞的協程,是一個隊列 WaitQ sendq; // list of send waiters };
寫管道操做底層由函數runtime·chansend實現,讀取管道操做底層由函數runtime·chanrecv實現。有兩種狀況會致使協程的阻塞:1)往管道寫入數據時,已達到該管道最大數據量;2)從管道讀取數據時,管道數據爲空。咱們以runtime·chansend爲例:
void runtime·chansend(ChanType *t, Hchan *c, byte *ep, bool *pres) { //管道爲nil,阻塞當前協程,觸發協程調度 if(c == nil) { g->status = Gwaiting; g->waitreason = "chan send (nil chan)"; runtime·gosched(); return; // not reached } if(c->dataqsiz > 0) { //有緩衝管道,寫入數據滿了,阻塞該協程 if(c->qcount >= c->dataqsiz) { g->status = Gwaiting; g->waitreason = "chan send"; enqueue(&c->sendq, &mysg); runtime·unlock(c); runtime·gosched(); } } …… }
更多詳細的實現細節,讀者能夠查看函數runtime·chansend與runtime·chanrecv實現邏輯。
socket讀寫怎麼處理呢?熟悉高併發服務端編程的應該都瞭解:基於IO多路複用模型,好比epoll。Golang也是這麼作的。
結構體pollServer封裝了事件循環相關,其定義以下:
type pollServer struct { //讀寫文件描述符,epoll在阻塞等待時候,可用於臨時喚醒(只要執行下寫或者讀操做便可) pr, pw *os.File //代理poll,底層可基於epoll/Kqueue等 poll *pollster // low-level OS hooks //socket-fd在讀寫時候一般都有超時時間;deadline爲最近的過時時間,用於設置epoll_wait阻塞時間 deadline int64 // next deadline (nsec since 1970) }
注:不瞭解epoll的讀者,搜索一下就有不少文章介紹。
Golang進程啓動時,會建立pollServer,並啓動事件循環,詳情參考函數newPollServer。
func newPollServer() (s *pollServer, err error) { s = new(pollServer) if s.pr, s.pw, err = os.Pipe(); err != nil { return nil, err } //設置非阻塞標識 if err = syscall.SetNonblock(int(s.pr.Fd()), true); err != nil { goto Errno } if err = syscall.SetNonblock(int(s.pw.Fd()), true); err != nil { goto Errno } //初始化代理poll:多是epoll/Kqueue等 if s.poll, err = newpollster(); err != nil { goto Error } //監聽s.pr,所以在向s.pw寫數據時候,能夠接觸epoll阻塞(以epoll爲例) if _, err = s.poll.AddFD(int(s.pr.Fd()), 'r', true); err != nil { s.poll.Close() goto Error } go s.Run() return s, nil }
注意到這裏經過go s.Run()啓動了一個協程,即事件循環是以獨立的協程在運行。事件循環無非就是,死循環,不斷經過epoll_wait阻塞等待socket事件的發生。
func (s *pollServer) Run() { for { var t = s.deadline //堵塞等待事件發生 fd, mode, err := s.poll.WaitFD(s, t) //超時了,沒有事件發生 if fd < 0 { s.CheckDeadlines() continue } //因爲s.pr接觸了阻塞,不是真正的socket-fd事件發生 if fd == int(s.pr.Fd()) { } else { netfd := s.LookupFD(fd, mode) //喚醒阻塞在該fd上的協程 s.WakeFD(netfd, mode, nil) } } }
看到這咱們大概明白了事件循環的邏輯,還有兩個問題須要肯定:1)socket讀寫操做實現邏輯;2)如何喚醒阻塞在該fd上的協程。
socket讀寫邏輯,由函數
pollServer.WaitRead或者pollServer.WaitWrite;即上層的網絡IO最終都會走到這裏。以WaitRead函數爲例:
func (s *pollServer) WaitRead(fd *netFD) error { err := s.AddFD(fd, 'r') if err == nil { err = <-fd.cr } return err }
s.AddFD最終將socket-fd添加到epoll,而且會更新pollServer.deadline,這是一個非阻塞操做;接下來只需等待事件循環監聽該fd讀/寫事件便可。讀管道fd.cr致使了該協程的阻塞。
基於這些,咱們很容易猜到,s.WakeFD喚醒阻塞在該fd上的協程,其實只須要往管道fd.cr/fd.cw寫下數據便可。
這幾個關鍵字應該是很常見的,特別是panic,很是讓人討厭。關於這幾個關鍵字的使用,這裏就不介紹了。咱們重點探索其底層實現原理。
defer以及panic定義在結構體G,結構體Defer以及Panic這裏就不作過多介紹了:
struct G { //該協程是否發生panic bool ispanic; //defer鏈表 Defer* defer; //panic鏈表 Panic* panic; }
咱們先探索第一個問題,都知道defer是先入後出的,爲何呢?函數執行結束時執行defer,又是怎麼實現的呢?defer關鍵字底層實現函數爲runtime·deferproc:
uintptr runtime·deferproc(int32 siz, byte* fn, ...){ //初始化結構體Defer d = runtime·malloc(sizeof(*d) + siz - sizeof(d->args)); d->fn = fn; d->siz = siz; //注意這裏設置了調用方待執行指令地址 d->pc = runtime·getcallerpc(&siz); //頭插法,後插入的節點在頭部;執行確實從頭部遍歷執行,所以就是先入後出 d->link = g->defer; g->defer = d; }
那defer何時執行呢?在函數結束時,Golang編譯器會在函數末尾添加runtime.deferreturn,用於執行函數fn,有興趣的讀者能夠寫個小示例,經過go tool compile -S -N -l test.go看看。
接下來咱們探索第二個問題:panic是怎麼觸發程序崩潰的;defer與recover又是如何恢復這種崩潰的;A協程中觸發panic,B協程中可否recover該panic呢?
關鍵字panic底層實現函數爲runtime·panic:
void runtime·panic(Eface e) { p = runtime·mal(sizeof *p); p->link = g->panic; g->panic = p; //遍歷執行當前協程的defer鏈表 for(;;) { d = g->defer; if(d == nil) break; g->defer = d->link; g->ispanic = true; //反射調用d->fn reflect·call(d->fn, d->args, d->siz); //recover底層實現爲runtime·recover,該函數會標記p->recovered=1 //若是已經執行了recover,則會消除此次崩潰 if(p->recovered) { //將該defer又加入到協程鏈表;調度時候有用 d->link = g->defer; g->defer = d; //恢復程序的執行 runtime·mcall(recovery); } } //若是沒有recover住,則會打印堆棧信息,並結束進程 runtime·startpanic(); printpanics(g->panic); runtime·dopanic(0); //runtime·exit(2) }
能夠看到,發生panic後,只會遍歷當前協程的defer鏈表,因此A協程中觸發panic,B協程中確定不能recover該panic。
最後一個問題,defer裏面recover以後,Golang程序從哪裏恢復執行呢?參考runtime·mcall(recovery),這就須要看函數recovery實現了:
static void recovery(G *gp) { //獲取第一個defer,即剛纔就是該defer recover了 d = gp->defer; gp->defer = d->link; //注意在初始化defer時候,設置了調用方待執行指令地址,這裏將其設置到協程調度上下文,從而恢復到這裏執行 gp->sched.pc = d->pc; //協程切換 runtime·gogo(&gp->sched, 1); }
注意在初始化defer時候,是如何設置pc的?基於函數runtime·getcallerpc。這樣獲取的是調用runtime.deferproc的下一條指令地址。
CALL runtime.deferproc(SB) TESTL AX, AX JNE 182
這裏經過TESTL校驗AX寄存器內容是正數負數仍是0值。AX寄存器存儲的是什麼呢?還須要繼續探索。
仔細看看,這裏協程切換時候,爲何runtime·gogo第二個參數是1呢?以前咱們一直沒有說第二個參數的做用。其實第二個參數是用做返回值的。參考runtime·gogo彙編實現,第二個參數拷貝到了寄存器AX,後面沒有任何代碼使用寄存器AX。
TEXT runtime·gogo(SB), 7, $0 MOVQ 16(SP), AX // return 2nd arg //省略
原來如此,runtime·gogo協程切換時候,設置的AX寄存器;在介紹虛擬內存章節,咱們也提到,寄存器AX能夠做爲函數返回值。其實函數runtime·deferproc也有明確的解釋:
// deferproc returns 0 normally. // a deferred func that stops a panic // makes the deferproc return 1. // the code the compiler generates always // checks the return value and jumps to the // end of the function if deferproc returns != 0. return 0;
runtime·deferproc一般返回0值,可是在出現panic,而且捕獲崩潰以後,runtime·deferproc返回1(基於runtime·gogo第二個參數以及AX寄存器實現)。這時候會經過JNE指令跳轉到runtime.deferreturn繼續執行,至關於函數執行結束。
最後咱們簡單畫一下該過程示意圖:
本文以Golang v1.0版本爲例,爲讀者講解協程實現原理,包括協程建立,協程切換,協程退出,以及g0協程。v1.0協程調度仍是比較簡單的,不少因素可能引發協程的阻塞觸發協程調度,本文簡單介紹了管道chan,以及socket事件循環。最後,針對defer/panic/recover,咱們介紹了其底層實現原理。