Golang號稱雲計算時代的C語言,是非常值得研究的一門語言
本文是筆者在初學Golang的時候,學習的一些新的分享。現在開一個系列,Golang究竟怎麼回事系列?談Goroutine,談數據結構,不僅語言語義理解,還要更深入的,更本質的看到,Golang的數據結構到底是怎麼回事?
其中,使用到gdb,dlv等調試工具,有此經驗的更佳。(旁白:這也是我更喜歡Golang的原因,可以使用gdb撥開雲霧,看到最本質的東西)
Goroutine思考幾個問題
-
協程是什麼,協程應用場景?
-
協程的調度實現有哪幾種樣式?有哪些常見的協程實現?
-
實現一個簡易協程調度
-
協程上最重要的準則是什麼?
-
有了協程要配套哪些東西?
前面有兩篇介紹協程的文章:
從簡單的講起,協程是什麼?
協程是什麼?
協程是什麼? 協程就是用戶態的最小調度執行單位,類比理解就是用戶態線程,本質就是用戶態自己切換cpu,在協程這一層我們基本可以把線程和cpu等同起來。(旁白:協程這個執行體操作系統是不認識的,只有用戶自己認識,所以你用pstack看線程的工具是看不了協程的)
協程應用場景?
-
IO密集型:IO密集型程序,cpu利用率低,使用協程,可以讓用戶按照實際情況調度,充分利用cpu,在當前多核cpu的架構中非常重要
-
框架改造:原本項目全是同步調用,cpu利用率低。直接改成異步回調不現實,通過實現協程,達到非侵入式的框架異步改造
-
協程的實現使用會使得全異步框架代碼的編寫簡單,可維護性好
(旁白:協程兩個用法:1)框架同步改造異步 2)異步代碼寫成同步樣子)
協程的調度實現一般有哪幾種樣式?
協程最根本的就兩種類型:
-
對稱的切換調度方式
-
非對稱的切換調度方式
對稱的調度方式
每個協程任務都是一樣的,不存在主次,都可以相互切換。這類調度類型看着美觀,但是實現起來會非常複雜,如果加上一些協程鎖,異步io切換邏輯之後,而且極容易出錯。不容易實現時序的串行化。
非對稱的調度方式
最典型的就是有一箇中心調度任務。主要角色分爲:
-
主協程:負責所有的協程調度
-
任務協程:執行具體的業務邏輯代碼的協程任務
基本原則:
-
嚴格保證所有的協程切換都必須且只能在 “主協程”<-> "任務協程” 之間進行
-
存在串行邏輯的時候,必須保證嚴格的串行時序(這個會在協程鎖的實現裏講)
有哪些常見的協程實現?
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);
-
glusterfs
-
qemu,等
或者你可以自己保存,交換寄存器棧環境:
-
libco (C++)
-
greenlet(Python),等
怎麼實現一個簡易的協程調度?
上圖是一個比較完整的切換示意圖:
-
主協程(調度協程)總是從協程隊列中取出協程任務執行
-
協程任務執行過程中,遇到等待事件,需要保存好上下文,設置好喚醒路徑之後,切回調度
-
切回調度之後,CPU就讓出來了,就可以執行其他的任務,從而實現了併發
-
等待事件到來之後,按照之前設置好的環境路徑,把協程任務再次投入到協程隊列尾端,等待執行
-
等重新取到協程的時候,主協程切入,從之前切出的地方開始執行
以上就是實現的一個簡單的協程調度的原理。當然具體細節會有很多,狀態修改,協程生命週期,校驗邏輯。比如必須:
-
加入爆棧的校驗(支不支持棧的自動擴容)
-
協程的生命週期的校驗
-
可能還需要做一些調試工具,比如查看某個協程的協程函數調用棧
-
死鎖檢查
-
比如,某個協程加了mutex阻塞鎖,走到後面代碼,就直接切到調度,那麼後面一旦有協程任務來加同一把mutex鎖,就會導致死鎖問題
協程上最重要的準則是什麼?
協程任務上一定不能跑阻塞的任務調用。一定要確保cpu不停的轉。因爲所有的協程當前本質上是不支持搶佔任務的,因爲沒有時間片的概念。一旦阻塞,會導致這個線程執行所有的任務阻塞。
協程要配套哪些東西?
- 協程鎖,條件變量,sleep,或者其他一切和阻塞有關的調用。
Goroutine的設計
前面複習完了協程通用的知識,下面終於到了重點戲碼——Golang的協程是怎麼回事? (旁白:協程實現很簡單,就四板斧:任務,隊列,切換上下文的手段,代碼執行者)
G-P-M的數據結構
作爲Go的最大宣傳特點,來看看goroutine的協程實現。goroutine本質上和上面我實現的協程是一樣的。但是由於做了一些層次抽象,更具靈活性。
-
G:Goroutine,一個G就是我們協程任務,是調度執行的單位。所以最重要的就是棧結構了(旁白:四板斧之一:任務)
-
M:Machine,這是一個抽象出來的數據結構,可以認爲就是執行體,就是線程,就是cpu,每個M都代表一個線程(旁白:四板斧之一:執行者)
-
P:processor。處理器,這個可以認爲就是代表一個硬件cpu核心。通常這個數量也就是和cpu核數相同(旁白:四板斧之一:隊列,Golang的設計就是得P者得天下,得隊列者得天下)
其中啓動開始P就是固定的,M是會增長的,M執行任務必須是綁定到一個P(也就是說,一定要有一個隊列),沒有綁定到P的M就是空閒的,或者遊離態的。這樣數據結構(P)和執行(M)分離增加了擴展性。
舉兩個例子:
-
如果M被阻塞,這個時候,隊列裏面所有的G都是要移交出去的,之前會存在比較複雜的操作。GMP架構,只需要M釋放P,空閒的M去接管P就行了。
-
如果當前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.
-
做一些初始化的操作
-
創建出一個goroutine結構 runtime.main 函數
-
執行runtime.mstart 函數
-
彙編引導結束,之後就由golang的函數main入口運行
初始化的時候,會創建幾個線程(M)
-
sysmon特殊線程
-
垃圾回收的線程
(旁白:goroutine有runtime的運行邏輯)
Goroutine調度
創建goroutine
接口
newproc
goroutine的調度跟之前我實現的協程調度核心是一致的,但是由於是多了一個抽象層(GPM),靈活性和擴展性大大提高。
-
go語言裏面go關鍵字用於創建goroutine(協程),實際調用的是newproc函數
-
newproc創建出一個goroutine結構體:G,分配2kb的協程棧(在systemstack環境下調用)
-
然後把G加入P隊列中,等待執行
-
切回原來的goroutine執行指令
**步驟一:**用來創建goroutine的結構
type funcval struct {
fn uintptr
// variable-size, fn-specific data here
}
注意:特意標紅的地方,這裏是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入隊的幾個優先級:
-
runqput
-
p.runqnext (第一優先級)
-
p.(runqhead, runqtail) 雙端隊列
-
runqputslow
-
sched.runq 全局隊列 (p隊列滿了就會溢出到全局隊列,p隊列256個槽位)
newproc -> newproc1 -> systemstack ( runqput )
步驟三:執行goroutine
調度接口入口
schedule
流程就是:
-
從隊列裏獲取到G
-
從P隊列裏獲取G任務
-
第二優先級從其他地方獲取
-
切入執行
這裏提到一點細節就是:go的調度機制是,當執行了n(61)個任務之後,必須要去全局列表獲取G任務,保證公平執行。
具體切入執行某個G
execute -> gogo
其中gogo的代碼
goroutine的搶佔調度
goroutine本質上是沒有搶佔式的調用,只是會在goroutine結構體上加上一個標記。因爲沒有時間片。只有當有機會調用到特定的調用的時候,纔可能發生切出。
goroutine的自動擴容
-
編譯器分析判斷是否可能會導致2kb的棧溢出,如果可能,那麼就會在函數的彙編代碼前後加上指令代碼
-
前面——判斷是否棧溢出
-
後——棧擴容調用morestack
(旁白:自動擴容的觸發機制也被複用在搶佔調度了)
goroutine的主動切出
-
Gosched : 把當前G放入到隊列中,然後切出
-
gopark/goparkunlock : 保存上下文,直接切出
-
goready : 喚醒G(把G重新入隊)
堅持思考,方向比努力更重要。關注我:奇伢雲存儲