探索Golang協程實現——從v1.0開始

LNMPR原始碼研究發表於2020-12-24

李樂

問題引入

  提起協程,你可能會說,不就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將記憶體組織為一些區域(段)的集合,如程式碼段,資料段,執行時堆,共享庫段,以及使用者棧都是不同的區域。如下圖所示:

image

  使用者棧,自上而下增長,暫存器%rsp指向使用者棧的棧頂位置;通過malloc分配的記憶體通常是在執行時堆。

  想想函式呼叫過程,比如func1呼叫func2,待func2執行完畢後,還會迴歸道func1繼續執行。該過程非常類似於棧結構,先入後出。大多數語言的函式呼叫都採用棧結構實現(基於使用者棧),函式的呼叫與返回即對應一系列的入棧與出棧操作,而我們平常遇到的棧溢位就是因為函式呼叫層級過深,不斷入棧導致的。函式棧幀如下圖所示:

image

  暫存器%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是一個特殊的協程,用於執行排程邏輯,以及協程建立銷燬等邏輯。

image

  Golang v1.0版本併發模型還是比較簡單的,這時候還沒有邏輯處理器P,只有MG,如下圖所示。注意這時候可執行協程佇列維護在全域性,因此每次排程都需要加鎖,效能是比較低的。

image

資料結構

  有幾個重要的結構體我們需要簡單瞭解下,比如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,
};

  協程狀態轉移如下圖所示:

image

協程建立

  通過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的初始化。整個過程如下圖所示:

image

協程切換

  協程建立,協程結束,協程因為某些原因阻塞,可能都會觸發協程的切換。

  如上一節介紹的函式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,在觸發協程排程時候,通常基於該函式完成。可以簡單畫下協程排程示意圖:

image

  有很多種情況可能會觸發協程排程:比如讀寫管道阻塞了,比如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繼續執行,相當於函式執行結束。

  最後我們簡單畫一下該過程示意圖:

image

總結

  本文以Golang v1.0版本為例,為讀者講解協程實現原理,包括協程建立,協程切換,協程退出,以及g0協程。v1.0協程排程還是比較簡單的,很多因素可能引起協程的阻塞觸發協程排程,本文簡單介紹了管道chan,以及socket事件迴圈。最後,針對defer/panic/recover,我們介紹了其底層實現原理。

相關文章