2. Go並發編程–GMP調度

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的個數問題

  1. P的數量:
    • 由啟動時環境變數 $GOMAXPROCS 或者是由 runtime 的方法 GOMAXPROCS() 決定。這意味著在程式執行的任意時刻都只有 $GOMAXPROCS 個 goroutine 在同時運行。
  2. M的數量:
    • go 語言本身的限制:go 程式啟動時,會設置 M 的最大數量,默認 10000. 但是內核很難支援這麼多的執行緒數,所以這個限制可以忽略。
    • runtime/debug 中的 SetMaxThreads 函數,設置 M 的最大數量
    • 一個 M 阻塞了,會創建新的 M。

M 與 P 的數量沒有絕對關係,一個 M 阻塞,P 就會去創建或者切換另一個 M,所以,即使 P 的默認數量是 1,也有可能會創建很多個 M 出來。

1.4 P 和 M 何時會被創建

  1. 在確定了 P 的最大數量 n 後,運行時系統會根據這個數量創建 n 個 P。
  2. 沒有足夠的 M 來關聯 P 並運行其中的可運行的 G。比如所有的 M 此時都阻塞住了,而 P 中還有很多就緒任務,就會去尋找空閑的 M,而沒有空閑的,就會去創建新的 M。

2. 調度器的設計策略

復用執行緒:避免頻繁的創建、銷毀執行緒,而是對執行緒的復用。

  1. work stealing 機制

    • 當本執行緒無可運行的 G 時,嘗試從其他執行緒綁定的 P 偷取 G,而不是銷毀執行緒。
  2. 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() 調度流程

從上圖我們可以分析出幾個結論:

  1. 通過 go func () 來創建一個 goroutine;
  2. 有兩個存儲 G 的隊列,一個是局部調度器 P 的本地隊列、一個是全局 G 隊列。新創建的 G 會先保存在 P 的本地隊列中,如果 P 的本地隊列已經滿了就會保存在全局的隊列中;
  3. G 只能運行在 M 中,一個 M 必須持有一個 P,M 與 P 是 1:1 的關係。M 會從 P 的本地隊列彈出一個可執行狀態的 G 來執行,如果 P 的本地隊列為空,就會想其他的 MP 組合偷取一個可執行的 G 來執行;
  4. 一個 M 調度 G 執行的過程是一個循環機制;
  5. 當 M 執行某一個 G 時候如果發生了 syscall 或則其餘阻塞操作,M 會阻塞,如果當前有一些 G 在執行,runtime 會把這個執行緒 M 從 P 中摘除 (detach),然後再創建一個新的作業系統的執行緒 (如果有空閑的執行緒可用就復用空閑執行緒) 來服務於這個 P
  6. 當 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")
}

會經歷如上圖所示的過程:

  1. runtime 創建最初的執行緒 m0 和 goroutine g0,並把 2 者關聯。
  2. 調度器初始化:初始化 m0、棧、垃圾回收,以及創建和初始化由 GOMAXPROCS 個 P 構成的 P 列表。
  3. 示例程式碼中的 main 函數是 main.main,runtime 中也有 1 個 main 函數 ——runtime.main,程式碼經過編譯後,runtime.main 會調用 main.main,程式啟動時會為 runtime.main 創建 goroutine,稱它為 main goroutine 吧,然後把 main goroutine 加入到 P 的本地隊列。
  4. 啟動 m0,m0 已經綁定了 P,會從 P 的本地隊列獲取 G,獲取到 main goroutine。
  5. G 擁有棧,M 根據 G 中的棧資訊和調度資訊設置運行環境
  6. M 運行G
  7. 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 //127.0.0.1:7925
    
  • 我們可以通過瀏覽器打開 //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

  1. 程式碼

    package main
    
    import (
        "fmt"
        "time"
    )
    
    func main() {
        for i := 0; i < 5; i++ {
            time.Sleep(time.Second)
            fmt.Println("Hello World")
        }
    }
    
  2. 編譯

    go build trace2.go
    
  3. 通過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 的數量。

6. 參考

  1. //www.topgoer.com/並發編程/GMP原理與調度.html
  2. //www.topgoer.cn/docs/gozhuanjia/gozhuanjiaxiecheng2