go 語言是如何實現協程的

go語言的精華就在於協程的設計,只有理解協程的設計思想和工作機制,才能確保我們能夠完全的利用協程編寫強大的併發程序。

Hi,我是 sharkChili ,是個不斷在硬核技術上作死的 java coder ,是 CSDN 的博客專家 ,也是開源項目 Java Guide 的維護者之一,熟悉 Java 也會一點 Go ,偶爾也會在 C 源碼 邊緣徘徊。寫過很多有意思的技術博客,也還在研究並輸出技術的路上,希望我的文章對你有幫助,非常歡迎你關注我的公衆號: 寫代碼的 SharkChili

詳解協程工作機制和實現

協程示例

正式介紹底層之前,我們給出一段協程的代碼示例,可以看到筆者開啓一個協程進行函數內部調用:

func foo1() {
 fmt.Println("foo1 調用 foo2")
 foo2()
}

func foo2() {
 fmt.Println("foo2調用foo3")
 foo3()
}

func foo3() {
 fmt.Println("foo3 執行了")
}

func main() {
 //設置WaitGroup等待協程結束
 var wg sync.WaitGroup
 wg.Add(1)

 go func() {
  foo1()
  defer wg.Done()
 }()

 //等待上述協程運行結束
 wg.Wait()
}

運行結果如下:

foo1 調用 foo2
foo2調用foo3
foo3 執行了

結合 debug 我們可以看到當前協程的調用棧幀,在函數調用前插入一個goexit的東西,結合這一點我們開始對協程的深入剖析:

協程實現結構

go語言的協程結構爲:

  1. 通過一個stack記錄其高地址和低地址。

  2. 通過schedsp(即stackpointer)棧幀的指針程序計數器pc(指向下一條運行的指令).

  3. 採用goid生成唯一標識。

  4. 然後再用atomicstatus記錄其執行狀態。

基於這幾點我們結合上述的代碼給出協程的底層結構,如下圖所示,當前協程的stack記錄整個 foo1 函數的高低地址,假設我們當前的協程go來到foo2函數準備調用foo3函數,我們的sched中的 sp 即stackpointer記錄 foo2 的指針,同時因爲foo2內部會調用foo3所以程序計數器pc記錄着調用foo3的指令。

最後因爲協程都是由線程調度的,所以協程的內部也有一個變量記錄着當前線程的指針 m:

到此我們瞭解了協程核心結構,同時我們也在runtime2.go這一文件中即給出上述所說的核心變量:

type g struct {
 //記錄棧幀的高地址和低地址
 stack       stack   // offset known to runtime/cgo
 //......
 m         *m //執行當前協程的線程指針
 //記錄當前堆棧的指針以及下一條指令的運行地址
 sched     gobuf
 atomicstatus atomic.Uint32
 goid         uint64
 
 //......
}

步入stack可以看到lohi兩個專門記錄棧幀高低地址的指針:

type stack struct {
 lo uintptr
 hi uintptr
}

對應的我們也給出sched 的類型gobuf,可以看到sppc兩個核心指針變量:

type gobuf struct {
 
 sp   uintptr
 pc   uintptr
 //......
}

談談 go 語言對於線程的抽象

上文我們提出線程的用m指針記錄,如下源碼所示,我們都知道在 go 語言中每個線程都會從一個協程隊列中獲取協程執行,所以執行時它會用curg記錄當前運行的協程,然後通過 id 對自己進行唯一標識,而mOS則是及記錄當前操作系統信息,這其中最核心的就是g0它就是每一個線程的操作調度器:

type m struct {
 g0      *g     // goroutine with scheduling stack
 id            int64 
 
 curg          *g       // current running goroutine
 
 mOS

}

瞭解整體結構之後我們再來聊聊 go 語言線程的 g0 棧是如何工作的,如下圖所示,每一個 g0 棧都會通過 schedule 開始工作:

  1. 通過 execute 從協程隊列中獲取任務。

  2. 調用 gogo 方法在協程調用前插入go exit指針它記錄 g0 棧幀,這個指針就是用於協程執行退出或者掛起是可以通過這個指針跳回g0棧。

  3. 然後就是執行當前協程。

  4. 協程執行完成切換回g0棧,重新調用 schedule 方法再次從步驟 1 開始執行,由此構成一個循環。

這裏我們也給出asm_amd64.s中關於gogo的彙編代碼,可以看到 gobuf_sp 方法它會記錄當前stack pointer也就是我們上文針對g0所說的g0棧地址:

TEXT gogo<>(SB), NOSPLIT, $0
 get_tls(CX)
 MOVQ DX, g(CX)
 MOVQ DX, R14  // set the g register
 //記錄g0棧地址
 MOVQ gobuf_sp(BX), SP // restore SP
 MOVQ gobuf_ret(BX), AX
 MOVQ gobuf_ctxt(BX), DX
 MOVQ gobuf_bp(BX), BP
 MOVQ $0, gobuf_sp(BX) // clear to help garbage collector
 MOVQ $0, gobuf_ret(BX)
 MOVQ $0, gobuf_ctxt(BX)
 MOVQ $0, gobuf_bp(BX)
 MOVQ gobuf_pc(BX), BX
 JMP BX

小結

自此我們從 go 語言底層實現的角度完整的剖析的協程與線程的關係和實現,希望對你有幫助。

我是 sharkchiliCSDN Java 領域博客專家開源項目—JavaGuide contributor,我想寫一些有意思的東西,希望對你有幫助,如果你想實時收到我寫的硬核的文章也歡迎你關注我的公衆號: 寫代碼的 SharkChili 。 因爲近期收到很多讀者的私信,所以也專門創建了一個交流羣,感興趣的讀者可以通過上方的公衆號獲取筆者的聯繫方式完成好友添加,點擊備註  “加羣”  即可和筆者和筆者的朋友們進行深入交流。

參考

程序計數器(PC)、堆棧指針(SP)與函數調用過程: https://www.cnblogs.com/uestcliming666/p/11488782.html

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ghDuP0PEaUworvl0Z6IhwA