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

李樂html

問題引入

  提起協程,你可能會說,不就go func嗎,我分分鐘就能建立上萬個協程。但是協程究竟是什麼呢?都說協程是用戶態線程,這裏的用戶態是什麼意思?都說協程比線程更輕量,協程輕量在哪裏呢?linux

  本文主要爲讀者介紹這些內容:git

  • Golang v1.0協程併發模型——MG模型,協程建立,協程切換,協程退出,以及g0協程,重在理解協程棧切換邏輯;
  • 爲了理解協程棧,還須要簡單瞭解下虛擬內存,函數棧幀以及簡單的彙編語言;
  • Golang v1.0協程調度邏輯;
  • defer,panic以及recover底層實現原理。

  經過本篇文章,你將從根本上了解Golang協程。github

  注:爲何選擇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,咱們介紹了其底層實現原理。

相關文章
相關標籤/搜索