2. Go併發程式設計--GMP排程

failymao發表於2021-09-22

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 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

  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. https://www.topgoer.com/併發程式設計/GMP原理與排程.html
  2. https://www.topgoer.cn/docs/gozhuanjia/gozhuanjiaxiecheng2

相關文章