1. 前言
GMP排程應該是被面試的時候問的頻率最高的問題!
我們知道,一切的軟體都是跑在作業系統上,真正用來幹活 (計算) 的是 CPU。早期的作業系統每個程式就是一個程式,知道一個程式執行完,才能進行下一個程式,就是 “單程式時代”
一切的程式只能序列發生。
1.1 Goroutine 排程器的 GMP 模型的設計思想
Processor,它包含了執行 goroutine 的資源,如果執行緒想執行 goroutine,必須先獲取 P,P 中還包含了可執行的 G 佇列。
1.2 GMP 模型
執行緒是執行 goroutine 的實體,排程器的功能是把可執行的 goroutine 分配到工作執行緒(M)上
- 全域性佇列(Global Queue):存放等待執行的 G。
- P為本地佇列:同全域性佇列類似,存放的也是等待執行的 Goroutine,存的數量有限,不超過 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 的核上執行。
1.3. 有關M和P的個數問題
- P的數量:
- 由啟動時環境變數 $GOMAXPROCS 或者是由 runtime 的方法 GOMAXPROCS() 決定。這意味著在程式執行的任意時刻都只有 $GOMAXPROCS 個 goroutine 在同時執行。
- M的數量:
- go 語言本身的限制:go 程式啟動時,會設定 M 的最大數量,預設 10000. 但是核心很難支援這麼多的執行緒數,所以這個限制可以忽略。
- runtime/debug 中的 SetMaxThreads 函式,設定 M 的最大數量
- 一個 M 阻塞了,會建立新的 M。
M 與 P 的數量沒有絕對關係,一個 M 阻塞,P 就會去建立或者切換另一個 M,所以,即使 P 的預設數量是 1,也有可能會建立很多個 M 出來。
1.4 P 和 M 何時會被建立
- 在確定了 P 的最大數量 n 後,執行時系統會根據這個數量建立 n 個 P。
- 沒有足夠的 M 來關聯 P 並執行其中的可執行的 G。比如所有的 M 此時都阻塞住了,而 P 中還有很多就緒任務,就會去尋找空閒的 M,而沒有空閒的,就會去建立新的 M。
2. 排程器的設計策略
複用執行緒:避免頻繁的建立、銷燬執行緒,而是對執行緒的複用。
-
work stealing 機制
- 當本執行緒無可執行的 G 時,嘗試從其他執行緒繫結的 P 偷取 G,而不是銷燬執行緒。
-
hand off 機制
- 當本執行緒因為 G 進行系統呼叫阻塞時,執行緒釋放繫結的 P,把 P 轉移給其他空閒的執行緒執行。
利用並行:GOMAXPROCS 設定 P 的數量,最多有 GOMAXPROCS 個執行緒分佈在多個 CPU 上同時執行。GOMAXPROCS 也限制了併發的程度,比如 GOMAXPROCS = 核數/2,則最多利用了一半的 CPU 核進行並行。
搶佔:在 coroutine 中要等待一個協程主動讓出 CPU 才執行下一個協程,在 Go 中,一個 goroutine 最多佔用 CPU 10ms,防止其他 goroutine 被餓死,這就是 goroutine 不同於 coroutine 的一個地方。
全域性 G 佇列:,當 M 執行 work stealing 從其他 P 偷不到 G 時,它可以從全域性 G 佇列獲取 G。
3. go fucn() 排程流程
從上圖我們可以分析出幾個結論:
- 通過 go func () 來建立一個 goroutine;
- 有兩個儲存 G 的佇列,一個是區域性排程器 P 的本地佇列、一個是全域性 G 佇列。新建立的 G 會先儲存在 P 的本地佇列中,如果 P 的本地佇列已經滿了就會儲存在全域性的佇列中;
- G 只能執行在 M 中,一個 M 必須持有一個 P,M 與 P 是 1:1 的關係。M 會從 P 的本地佇列彈出一個可執行狀態的 G 來執行,如果 P 的本地佇列為空,就會想其他的 MP 組合偷取一個可執行的 G 來執行;
- 一個 M 排程 G 執行的過程是一個迴圈機制;
- 當 M 執行某一個 G 時候如果發生了 syscall 或則其餘阻塞操作,M 會阻塞,如果當前有一些 G 在執行,runtime 會把這個執行緒 M 從 P 中摘除 (detach),然後再建立一個新的作業系統的執行緒 (如果有空閒的執行緒可用就複用空閒執行緒) 來服務於這個 P
- 當 M 系統呼叫結束時候,這個 G 會嘗試獲取一個空閒的 P 執行,並放入到這個 P 的本地佇列。如果獲取不到 P,那麼這個執行緒 M 變成休眠狀態, 加入到空閒執行緒中,然後這個 G 會被放入全域性佇列中。
4. 排程器的生命週期
4.1 特殊的 M0 和 G0
M0
M0 是啟動程式後的編號為 0 的主執行緒,這個 M 對應的例項會在全域性變數 runtime.m0 中,不需要在 heap 上分配,M0 負責執行初始化操作和啟動第一個 G, 在之後 M0 就和其他的 M 一樣了。
G0
G0 是每次啟動一個 M 都會第一個建立的 gourtine,G0 僅用於負責排程的 G,G0 不指向任何可執行的函式,每個 M 都會有一個自己的 G0。在排程或系統呼叫時會使用 G0 的棧空間,全域性變數的 G0 是 M0 的 G0。
4.2 示例程式碼說明
package main
import "fmt"
func main() {
fmt.Println("Hello world")
}
會經歷如上圖所示的過程:
- 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 結束而結束。
5. 視覺化 GMP 程式設計
有 2 種方式可以檢視一個程式的 GMP 的資料。
5.1 方式 1:go tool trace
trace 記錄了執行時的資訊,能提供視覺化的 Web 頁面。
簡單測試程式碼:main 函式建立 trace,trace 會執行在單獨的 goroutine 中,然後 main 列印 "Hello World" 退出
- trace.go
package main import ( "os" "fmt" "runtime/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 World") }
- 執行程式
$ go run trace.go Hello World
- 會得到一個 trace.out 檔案,然後我們可以用一個工具開啟,來分析這個檔案。
$ go tool trace trace.out /09/21 22:14:22 Parsing trace... 2021/09/21 22:14:22 Splitting trace... 2021/09/21 22:14:22 Opening browser. Trace viewer is listening on http://127.0.0.1:7925
- 我們可以通過瀏覽器開啟 http://127.0.0.1:7925 網址,點選 view trace 能夠看見視覺化的排程流程。
G資訊
點選 Goroutines 那一行視覺化的資料條,我們會看到一些詳細的資訊。
一共有兩個G在程式中,一個是特殊的G0,因為每個M必須有的一個初始化的G
M 資訊
點選 Threads 那一行視覺化的資料條,我們會看到一些詳細的資訊。
一共有兩個 M 在程式中,一個是特殊的 M0,用於初始化使用
P資訊
G1 中呼叫了 main.main,建立了 trace goroutine g19。G1 執行在 P1 上,G19 執行在 P0 上。
這裡有兩個 P,我們知道,一個 P 必須繫結一個 M 才能排程 G。
來看看上面的 M 資訊。
確實 G19 在 P0 上被執行的時候,確實在 Threads 行多了一個 M 的資料
多了一個 M2 應該就是 P0 為了執行 G19 而動態建立的 M2.
5.3 方式 2:Debug trace
-
程式碼
package main import ( "fmt" "time" ) func main() { for i := 0; i < 5; i++ { time.Sleep(time.Second) fmt.Println("Hello World") } }
-
編譯
go build trace2.go
-
通過debug方式執行
GODEBUG=schedtrace=1000 ./trace2 SCHED 0ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=1 idlethreads=1 runqueue=0 [0 0] Hello World SCHED 1003ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0] Hello World SCHED 2014ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0] Hello World SCHED 3015ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0] Hello World SCHED 4023ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0] Hello World
- SCHED:除錯資訊輸出標誌字串,代表本行是 goroutine 排程器的輸出;
- 0ms:即從程式啟動到輸出這行日誌的時間;
- gomaxprocs: P 的數量,本例有 2 個 P, 因為預設的 P 的屬性是和 cpu 核心數量預設一致,當然也可以通過 GOMAXPROCS 來設定;
- 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]: 分別為 2 個 P 的 local queue 中的 G 的數量。