李樂
問題引入
提起協程,你可能會說,不就go func嗎,我分分鐘就能建立上萬個協程。可是協程到底是什麼呢?都說協程是使用者態執行緒,這裡的使用者態是什麼意思?都說協程比執行緒更輕量,協程輕量在哪裡呢?
本文主要為讀者介紹這些內容:
- Golang v1.0協程併發模型——MG模型,協程建立,協程切換,協程退出,以及g0協程,重在理解協程棧切換邏輯;
- 為了理解協程棧,還需要簡單瞭解下虛擬記憶體,函式棧幀以及簡單的組合語言;
- Golang v1.0協程排程邏輯;
- defer,panic以及recover底層實現原理。
通過本篇文章,你將從根本上了解Golang協程。
注:為什麼選擇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彙編語法,彙編指令的寫法以及暫存器的命名略有不同
下面簡單介紹一些常用的指令以及暫存器:
- MOVQ $10, AX:資料移動指令,該指令表示將立即數10儲存在暫存器AX;AX即通用暫存器,常用的通用暫存器還有BX,CX,DX等等;注意指令字尾『Q』表示資料長度為8位元組;
- ADDQ AX, BX:加法指令,等價於 BX += AX;
- SUBQ AX, BX:減法指令,等價於 BX -= AX;
- JMP addr:跳轉道addr地址處繼續執行;
- JMP 2(PC):CPU如何載入指令並執行呢?其實有個專用暫存器PC(等價於%rip),他指向下一條待執行的指令。該語句含義是,以當前指令為基礎,向後跳轉2行;
- FP:偽暫存器,通過symbol+offset(FP)形式,引用函式的輸入引數,例如 arg0+0(FP),arg1+8(FP);
- 硬體暫存器SP:等價於上面出現過的%rsp,執行函式棧幀頂部位置);
- CALL func:函式呼叫,包含兩個步驟,1)將下一條指令的所在地址入棧(還需要恢復到這執行);2)將func地址,儲存在指令暫存器PC;
- RET:函式返回,功能為,從棧上彈出指令到指令暫存器PC,恢復呼叫方函式的執行(CALL指令入棧);
更多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...
v1.0協程模型
很多人對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棧)上執行。
所以隨處可見這樣的邏輯:
//協程棧申請,必須在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();
}
v1.0協程排程
排程器負責維護協程狀態,獲取一個可執行協程並執行。排程邏輯主要在函式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操作等等,下面將分別介紹。
管道channel
管道通常用於協程間的資料互動,管道的結構體定義如下:
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事件迴圈
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寫下資料即可。
defer/panic/recover
這幾個關鍵字應該是很常見的,特別是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,我們介紹了其底層實現原理。