Go排程器系列(2)巨集觀看排程器
轉載宣告如下:
- 本文作者:大彬
- 原文連結:https://lessisbetter.site/2019/03/26/golang-scheduler-2-macro-view
上一篇文章《Go語言高階:排程器系列(1)起源》,學goroutine排程器之前的一些背景知識,這篇文章則是為了對排程器有個巨集觀的認識,從巨集觀的3個角度,去看待和理解排程器是什麼樣子的,但仍然不涉及具體的排程原理。
三個角度分別是:
- 排程器的巨集觀組成
- 排程器的生命週期
- GMP的視覺化感受
在開始前,先回憶下排程器相關的3個縮寫:
- G: goroutine,每個G都代表1個goroutine
- M: 工作執行緒,是Go語言定義出來在使用者層面描述系統執行緒的物件 ,每個M代表一個系統執行緒
- P: 處理器,它包含了執行Go程式碼的資源。
3者的簡要關係是P擁有G,M必須和一個P關聯才能執行P擁有的G。
排程器的功能
《Go語言高階:排程器系列(1)起源》中介紹了協程和執行緒的關係,協程需要執行線上程之上,執行緒由CPU進行排程。
在Go中,執行緒是執行goroutine的實體,排程器的功能是把可執行的goroutine分配到工作執行緒上。
Go的排程器也是經過了多個版本的開發才是現在這個樣子的,
- 1.0版本釋出了最初的、最簡單的排程器,是G-M模型,存在4類問題
- 1.1版本重新設計,修改為G-P-M模型,奠定當前排程器基本模樣
- 1.2版本加入了搶佔式排程,防止協程不讓出CPU導致其他G餓死
在
$GOROOT/src/runtime/proc.go
的開頭註釋中包含了對Scheduler的重要註釋,介紹Scheduler的設計曾拒絕過3種方案以及原因,本文不再介紹了,希望你不要忽略為數不多的官方介紹。
Scheduler的巨集觀組成
Tony Bai在《也談goroutine排程器》中的這幅圖,展示了goroutine排程器和系統排程器的關係,而不是把二者割裂開來,並且從巨集觀的角度展示了排程器的重要組成。
自頂向下是排程器的4個部分:
- 全域性佇列(Global Queue):存放等待執行的G。
- P的本地佇列:同全域性佇列類似,存放的也是等待執行的G,存的數量有限,不超過256個。新建G’時,G’優先加入到P的本地佇列,如果佇列滿了,則會把本地佇列中一半的G移動到全域性佇列。
- P列表:所有的P都在程式啟動時建立,並儲存在陣列中,最多有GOMAXPROCS個。
- M:執行緒想執行任務就得獲取P,從P的本地佇列獲取G,P佇列為空時,M也會嘗試從全域性佇列拿一批G放到P的本地佇列,或從其他P的本地佇列偷一半放到自己P的本地佇列。M執行G,G執行之後,M會從P獲取下一個G,不斷重複下去。
Goroutine排程器和OS排程器是通過M結合起來的,每個M都代表了1個核心執行緒,OS排程器負責把核心執行緒分配到CPU的核上執行。
排程器的生命週期
接下來我們從另外一個巨集觀角度——生命週期,認識排程器。
所有的Go程式執行都會經過一個完整的排程器生命週期:從建立到結束。
即使下面這段簡單的程式碼:
package main import "fmt" // main.main func main() { fmt.Println("Hello scheduler") }
也會經歷如上圖所示的過程:
- runtime建立最初的執行緒m0和goroutine g0,並把2者關聯。
- 排程器初始化:初始化m0、棧、垃圾回收,以及建立和初始化由GOMAXPROCS個P構成的P列表。
- 示例程式碼中的main函式是
main.main
,runtime
中也有1個main函式——runtime.main
,程式碼經過編譯後,runtime.main
會呼叫main.main
,程式啟動時會為runtime.main
建立goroutine,稱它為main goroutine吧,然後把main goroutine加入到P的本地佇列。 - 啟動m0,m0已經綁定了P,會從P的本地佇列獲取G,獲取到main goroutine。
- G擁有棧,M根據G中的棧資訊和排程資訊設定執行環境
- M執行G
- G退出,再次回到M獲取可執行的G,這樣重複下去,直到
main.main
退出,runtime.main
執行Defer和Panic處理,或呼叫runtime.exit
退出程式。
排程器的生命週期幾乎佔滿了一個Go程式的一生,runtime.main
的goroutine執行之前都是為排程器做準備工作,runtime.main
的goroutine執行,才是排程器的真正開始,直到runtime.main
結束而結束。
GMP的視覺化感受
上面的兩個巨集觀角度,都是根據文件、程式碼整理出來,最後我們從視覺化角度感受下排程器,有2種方式。
方式1:go tool trace
trace記錄了執行時的資訊,能提供視覺化的Web頁面。
簡單測試程式碼:main函式建立trace,trace會執行在單獨的goroutine中,然後main列印”Hello trace”退出。
func main() { // 建立trace檔案 f, err := os.Create("trace.out") if err != nil { panic(err) } defer f.Close() // 啟動trace goroutine err = trace.Start(f) if err != nil { panic(err) } defer trace.Stop() // main fmt.Println("Hello trace") }
執行程式和執行trace:
➜ trace git:(master) ✗ go run trace1.go Hello trace ➜ trace git:(master) ✗ ls trace.out trace1.go ➜ trace git:(master) ✗ ➜ trace git:(master) ✗ go tool trace trace.out 2019/03/24 20:48:22 Parsing trace... 2019/03/24 20:48:22 Splitting trace... 2019/03/24 20:48:22 Opening browser. Trace viewer is listening on http://127.0.0.1:55984
效果:
從上至下分別是goroutine(G)、堆、執行緒(M)、Proc(P)的資訊,從左到右是時間線。用滑鼠點選顏色塊,最下面會列出詳細的資訊。
我們可以發現:
runtime.main
的goroutine是g1
,這個編號應該永遠都不變的,runtime.main
是在g0
之後建立的第一個goroutine。- g1中呼叫了
main.main
,建立了trace goroutine g18
。g1執行在P2上,g18執行在P0上。 - P1上實際上也有goroutine執行,可以看到短暫的豎線。
go tool trace的資料並不多,如果感興趣可閱讀:https://making.pusher.com/go-tool-trace/,中文翻譯是:https://mp.weixin.qq.com/s/nf_-AH_LeBN3913Pt6CzQQ。
方式2:Debug trace
示例程式碼:
// main.main func main() { for i := 0; i < 5; i++ { time.Sleep(time.Second) fmt.Println("Hello scheduler") } }
編譯和執行,執行過程會列印trace:
➜ one_routine2 git:(master) ✗ go build .
➜ one_routine2 git:(master) ✗ GODEBUG=schedtrace=1000 ./one_routine2
結果:
1
|
SCHED 0ms: gomaxprocs=8 idleprocs=5 threads=5 spinningthreads=1 idlethreads=0 runqueue=0 [0 0 0 0 0 0 0 0]
|
看到這密密麻麻的文字就有點擔心,不要愁!因為每行欄位都是一樣的,各欄位含義如下:
- SCHED:除錯資訊輸出標誌字串,代表本行是goroutine排程器的輸出;
- 0ms:即從程式啟動到輸出這行日誌的時間;
- gomaxprocs: P的數量,本例有8個P;
- idleprocs: 處於idle狀態的P的數量;通過gomaxprocs和idleprocs的差值,我們就可知道執行go程式碼的P的數量;
- threads: os threads/M的數量,包含scheduler使用的m數量,加上runtime自用的類似sysmon這樣的thread的數量;
- spinningthreads: 處於自旋狀態的os thread數量;
- idlethread: 處於idle狀態的os thread的數量;
- runqueue=0: Scheduler全域性佇列中G的數量;
[0 0 0 0 0 0 0 0]
: 分別為8個P的local queue中的G的數量。
看第一行,含義是:剛啟動時建立了8個P,其中5個空閒的P,共建立5個M,其中1個M處於自旋,沒有M處於空閒,8個P的本地佇列都沒有G。
再看個複雜版本的,加上scheddetail=1
可以列印更詳細的trace資訊。
命令:
➜ one_routine2 git:(master) ✗ GODEBUG=schedtrace=1000,scheddetail=1 ./one_routine2
結果:
截圖可能更程式碼匹配不起來,最初程式碼是for死迴圈,後面為了減少列印加了限制迴圈5次
每次分別列印了每個P、M、G的資訊,P的數量等於gomaxprocs
,M的數量等於threads
,主要看圈黃的地方:
- 第1處:P1和M2進行了繫結。
- 第2處:M2和P1進行了繫結,但M2上沒有執行的G。
- 第3處:程式碼中使用fmt進行列印,會進行系統呼叫,P1系統呼叫的次數很多,說明我們的用例函式基本在P1上執行。
- 第4處和第5處:M0上運行了G1,G1的狀態為3(系統呼叫),G進行系統呼叫時,M會和P解綁,但M會記住之前的P,所以M0仍然記綁定了P1,而P1稱未繫結M。
總結時刻
這篇文章,從3個巨集觀的角度介紹了排程器,也許你依然不知道排程器的原理,心裡感覺模模糊糊,沒關係,一步一步走,通過這篇文章希望你瞭解了:
- Go排程器和OS排程器的關係
- Go排程器的生命週期/總體流程
- P的數量等於GOMAXPROCS
- M需要通過繫結的P獲取G,然後執行G,不斷重複這個過程
示例程式碼
本文所有示例程式碼都在Github,可通過閱讀原文訪問:golang_step_by_step/tree/master/scheduler