Golang 協程Goroutine到底是怎麼回事?(一)

Golang號稱雲計算時代的C語言,是非常值得研究的一門語言

本文是筆者在初學Golang的時候,學習的一些新的分享。現在開一個系列,Golang究竟怎麼回事系列?談Goroutine,談數據結構,不僅語言語義理解,還要更深入的,更本質的看到,Golang的數據結構到底是怎麼回事?

其中,使用到gdb,dlv等調試工具,有此經驗的更佳。(旁白:這也是我更喜歡Golang的原因,可以使用gdb撥開雲霧,看到最本質的東西)

Goroutine思考幾個問題

  1. 協程是什麼,協程應用場景?

  2. 協程的調度實現有哪幾種樣式?有哪些常見的協程實現?

  3. 實現一個簡易協程調度

  4. 協程上最重要的準則是什麼?

  5. 有了協程要配套哪些東西?

前面有兩篇介紹協程的文章:

  1. 同步框架異步化改造—任務協程化 (一)

  2. 同步框架異步化改造—任務協程化 (二)

從簡單的講起,協程是什麼?

協程是什麼?

協程是什麼? 協程就是用戶態的最小調度執行單位,類比理解就是用戶態線程,本質就是用戶態自己切換cpu,在協程這一層我們基本可以把線程和cpu等同起來。(旁白:協程這個執行體操作系統是不認識的,只有用戶自己認識,所以你用pstack看線程的工具是看不了協程的)

協程應用場景?

  1. IO密集型:IO密集型程序,cpu利用率低,使用協程,可以讓用戶按照實際情況調度,充分利用cpu,在當前多核cpu的架構中非常重要

  2. 框架改造:原本項目全是同步調用,cpu利用率低。直接改成異步回調不現實,通過實現協程,達到非侵入式的框架異步改造

  3. 協程的實現使用會使得全異步框架代碼的編寫簡單,可維護性好

(旁白:協程兩個用法:1)框架同步改造異步    2)異步代碼寫成同步樣子)

協程的調度實現一般有哪幾種樣式?

協程最根本的就兩種類型:

  1. 對稱的切換調度方式

  2. 非對稱的切換調度方式

對稱的調度方式

每個協程任務都是一樣的,不存在主次,都可以相互切換。這類調度類型看着美觀,但是實現起來會非常複雜,如果加上一些協程鎖,異步io切換邏輯之後,而且極容易出錯。不容易實現時序的串行化。

image

非對稱的調度方式

最典型的就是有一箇中心調度任務。主要角色分爲:

  1. 主協程:負責所有的協程調度

  2. 任務協程:執行具體的業務邏輯代碼的協程任務

基本原則:

  1. 嚴格保證所有的協程切換都必須且只能在 “主協程”<-> "任務協程” 之間進行

  2. 存在串行邏輯的時候,必須保證嚴格的串行時序(這個會在協程鎖的實現裏講)

image

有哪些常見的協程實現?

Linux提供了協程庫,可以基於以下這四個調用實現協程切換

#include <ucontext.h>
void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...);
int  swapcontext(ucontext_t *oucp, const ucontext_t *ucp);
int  getcontext(ucontext_t *ucp);
int  setcontext(const ucontext_t *ucp);
  1. glusterfs

  2. qemu,等

或者你可以自己保存,交換寄存器棧環境:

  1. libco (C++)

  2. greenlet(Python),等

怎麼實現一個簡易的協程調度?

image

上圖是一個比較完整的切換示意圖:

  1. 主協程(調度協程)總是從協程隊列中取出協程任務執行

  2. 協程任務執行過程中,遇到等待事件,需要保存好上下文,設置好喚醒路徑之後,切回調度

  3. 切回調度之後,CPU就讓出來了,就可以執行其他的任務,從而實現了併發

  4. 等待事件到來之後,按照之前設置好的環境路徑,把協程任務再次投入到協程隊列尾端,等待執行

  5. 等重新取到協程的時候,主協程切入,從之前切出的地方開始執行

以上就是實現的一個簡單的協程調度的原理。當然具體細節會有很多,狀態修改,協程生命週期,校驗邏輯。比如必須:

  1. 加入爆棧的校驗(支不支持棧的自動擴容)

  2. 協程的生命週期的校驗

  3. 可能還需要做一些調試工具,比如查看某個協程的協程函數調用棧

  4. 死鎖檢查

  5. 比如,某個協程加了mutex阻塞鎖,走到後面代碼,就直接切到調度,那麼後面一旦有協程任務來加同一把mutex鎖,就會導致死鎖問題

協程上最重要的準則是什麼?

協程任務上一定不能跑阻塞的任務調用。一定要確保cpu不停的轉。因爲所有的協程當前本質上是不支持搶佔任務的,因爲沒有時間片的概念。一旦阻塞,會導致這個線程執行所有的任務阻塞。

協程要配套哪些東西?

  1. 協程鎖,條件變量,sleep,或者其他一切和阻塞有關的調用。

Goroutine的設計

前面複習完了協程通用的知識,下面終於到了重點戲碼——Golang的協程是怎麼回事? (旁白:協程實現很簡單,就四板斧:任務,隊列,切換上下文的手段,代碼執行者)
G-P-M的數據結構

image

作爲Go的最大宣傳特點,來看看goroutine的協程實現。goroutine本質上和上面我實現的協程是一樣的。但是由於做了一些層次抽象,更具靈活性。

  • G:Goroutine,一個G就是我們協程任務,是調度執行的單位。所以最重要的就是棧結構了(旁白:四板斧之一:任務)

  • M:Machine,這是一個抽象出來的數據結構,可以認爲就是執行體,就是線程,就是cpu,每個M都代表一個線程(旁白:四板斧之一:執行者)

  • P:processor。處理器,這個可以認爲就是代表一個硬件cpu核心。通常這個數量也就是和cpu核數相同(旁白:四板斧之一:隊列,Golang的設計就是得P者得天下,得隊列者得天下)

其中啓動開始P就是固定的,M是會增長的,M執行任務必須是綁定到一個P(也就是說,一定要有一個隊列),沒有綁定到P的M就是空閒的,或者遊離態的。這樣數據結構(P)和執行(M)分離增加了擴展性。

舉兩個例子:

  1. 如果M被阻塞,這個時候,隊列裏面所有的G都是要移交出去的,之前會存在比較複雜的操作。GMP架構,只需要M釋放P,空閒的M去接管P就行了。

  2. 如果當前M執行完了P隊列的所有任務,那麼也不會空閒等待,而是會嘗試去steal其他的G。先嚐試從全局隊列裏獲取,沒有獲取到,那麼再去隨機挑選一個P隊列,拿走部分的G。(worke-steal)

這個GMP的設計是在Go1.1之後加入的:https://docs.google.com/document/d/1TTj4T2JO42uD5ID9e89oa0sLKhJYD0Y_kqxDv3I3XMw/edit#heading=h.mmq8lm48qfcw

提一下:go裏面實現一些併發同步操作的時候,很多都是使用原子操作來替代鎖,從而減少消耗,這個值得我們學習。

有些特殊的M,比如sysmon是不綁定P的。這個用於監控一些阻塞的異常情況,比如一個M長時間阻塞超過10ms,那麼強制把M-P解綁,把M遊離出去,P綁定到一個空閒的M上,繼續執行隊列裏的G任務。

Go程序啓動

// The bootstrap sequence is:
//
// call osinit
// call schedinit
// make & queue new G
// call runtime·mstart
//
// The new G calls runtime·main.
  1. 做一些初始化的操作

  2. 創建出一個goroutine結構 runtime.main 函數

  3. 執行runtime.mstart 函數

  4. 彙編引導結束,之後就由golang的函數main入口運行

image.gif

初始化的時候,會創建幾個線程(M)

  1. sysmon特殊線程

  2. 垃圾回收的線程

(旁白:goroutine有runtime的運行邏輯)

Goroutine調度

image

創建goroutine

接口

newproc

goroutine的調度跟之前我實現的協程調度核心是一致的,但是由於是多了一個抽象層(GPM),靈活性和擴展性大大提高。

  1. go語言裏面go關鍵字用於創建goroutine(協程),實際調用的是newproc函數

  2. newproc創建出一個goroutine結構體:G,分配2kb的協程棧(在systemstack環境下調用)

  3. 然後把G加入P隊列中,等待執行

  4. 切回原來的goroutine執行指令

**步驟一:**用來創建goroutine的結構

type funcval struct {
   fn uintptr
   // variable-size, fn-specific data here
}

image

注意:特意標紅的地方,這裏是goroutine調度的一個關鍵。在goroutine執行完fn函數之後,在執行ret彙編指令的時候,會把這個地址取出來放到指令計數器(pc)去執行,而這個地址恰好是goexit的地址。這個賦值就是在newproc的時候賦值的。執行了goexit,你才能切回調度裏(非對稱中心化調度)。

newproc -> newproc1 -> gostartcallfn

**步驟二: **newproc是在systemstack的包裝下調用的,這個調用保證newproc的函數執行是在調度協程的棧裏面(M.g0棧)


// func systemstack(fn func())
TEXT runtime·systemstack(SB), NOSPLIT, $0-8
...
// 切換到調度: switch to g0
MOVQ   DX, g(CX)
MOVQ   (g_sched+gobuf_sp)(DX), BX
SUBQ   $8, BX
MOVQ   $runtime·mstart(SB), DX
MOVQ   DX, 0(BX)
MOVQ   BX, SP

// 執行函數:call target function
MOVQ   DI, DX
MOVQ   0(DI), DI
CALL   DI

// 切回原來的協程:switch back to g
MOVQ   g(CX), AX
MOVQ   g_m(AX), BX
MOVQ   m_curg(BX), AX
MOVQ   AX, g(CX)
MOVQ   (g_sched+gobuf_sp)(AX), SP
MOVQ   $0, (g_sched+gobuf_sp)(AX)

這個就符合中心調度的設計思想。解釋幾個函數調用

runqget  // goroutine 出隊
runqput  // goroutine 入隊
runqgrab // goroutine 搶佔

G入隊的幾個優先級:

  1. runqput

  2. p.runqnext (第一優先級)

  3. p.(runqhead, runqtail) 雙端隊列

  4. runqputslow

  5. sched.runq 全局隊列 (p隊列滿了就會溢出到全局隊列,p隊列256個槽位)

newproc -> newproc1 -> systemstack ( runqput )

步驟三:執行goroutine

調度接口入口

schedule

流程就是:

  1. 從隊列裏獲取到G

  2. 從P隊列裏獲取G任務

  3. 第二優先級從其他地方獲取

  4. 切入執行

這裏提到一點細節就是:go的調度機制是,當執行了n(61)個任務之後,必須要去全局列表獲取G任務,保證公平執行。

具體切入執行某個G

execute -> gogo

其中gogo的代碼

image

goroutine的搶佔調度

goroutine本質上是沒有搶佔式的調用,只是會在goroutine結構體上加上一個標記。因爲沒有時間片。只有當有機會調用到特定的調用的時候,纔可能發生切出。

goroutine的自動擴容

  1. 編譯器分析判斷是否可能會導致2kb的棧溢出,如果可能,那麼就會在函數的彙編代碼前後加上指令代碼

  2. 前面——判斷是否棧溢出

  3. 後——棧擴容調用morestack

(旁白:自動擴容的觸發機制也被複用在搶佔調度了)

goroutine的主動切出

  1. Gosched : 把當前G放入到隊列中,然後切出

  2. gopark/goparkunlock : 保存上下文,直接切出

  3. goready : 喚醒G(把G重新入隊)

image


堅持思考,方向比努力更重要。關注我:奇伢雲存儲image

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章