Golang調度器和GMP模型

一、調度器的由來

調度本身是指操作系統中為每個任務分配其所需資源的方法。

在操作系充中,線程是任務執行的最小單位,是系統調度的基本單元。

雖然線程比進程輕量,但是在調度時也有比較大的額外開銷,每個線程都會占用幾 M 的內存,上下文切換時也會消耗幾微秒的時間,這些都是高并發的阻礙。

Go 語言的誕生有一個很重要的目的就是并發編程,所以開發者為Go語言設計了一個調度器來解決這些問題。

Go 的調度器通過使用與 CPU 數量相等的線程減少線程頻繁切換的內存和時間開銷,同時在每一個線程上執行額外開銷更低的協程來降低資源占用,完成更高并發。

1.1 Go 調度器的歷史

  • 單線程調度器

最初的調度器極其簡陋,只有 40 多行代碼,程序只能存在一個活躍線程,由 G (goroutine) - M (thread)組成。

  • 多線程調度器

加入了多線程后,可以運行多線程任務了,但是存在嚴重的同步競爭問題。

  • 任務竊取調度器

引入了處理器 P (processor),構成了 GMP 模型。P 是調度器的中心,任何線程想要運行 G ,都必須服務 P 的安排,由 P 完成任務竊取。如果 G 發生阻塞,G 并不會主動讓出線程,導致其他 G 饑餓。

另外,這一版的調度器的垃圾回收機制使用了比較笨重的 SWT (stop the world) 機制,在回收垃圾時,會暫停程序很長時間。

  • 搶占式調度器(正在使用)

搶占式調度器也發展了兩個階段:

  1. 基于協作的搶占式調度器(1.12 ~ 1.13)。編譯器在函數調用時檢查當前goroutine是否發起搶占請求。也有 STW 的問題。
  2. 基于信號的搶占式調度器(1.14 至今)。垃圾回收在掃描棧時會觸發搶占調度,但搶占的點不夠多,不能覆蓋全部的邊緣情況。
  • 非均勻存儲訪問調度器(提案)

對運行時的各種資源進行分區,但實現非常復雜,只停留在理論階段。

1.2 單線程調度器

最初的調度器只包括 GM 兩種結構,全局只有一個線程,所以系統中只能一個任務一個任務地執行,一旦發生阻塞,整個后續任務都無法執行。

單進程調度器的意義就是建立了 GM 結構,為后續的調度器發展打下了基礎。

1.3 多線程調度器

在 1.0 版本中,Go 更換了多線程調度器,與單線程調度器相比,可以說是質的飛躍。

多線程調度器的整體邏輯與單線程調度器沒有太大的區別,因為引入了多線程,所以 Go 使用環境變量GOMAXPROCS幫助我們靈活控制程序中的最大處理器數,即活躍線程數。

多線程調度器的主要問題是調度時的鎖競爭會嚴重浪費資源,有14%的時間浪費在此。

主要問題有:

  1. 調度器和鎖是全局資源,所有的調度狀態都是中心化存儲的,鎖競爭嚴重
  2. 線程需要經常互相傳遞可運行的 Goroutine,引入了大量的延遲
  3. 每個線程都需要處理內存緩存,導致大量的內存占用并影響數據局部性
  4. 系統調用頻繁阻塞和解除阻塞正在運行的線程,增加額外開銷

1.4 任務竊取調度器

基于任務竊取的Go語言調度器使用了沿用至今的 GMP 模型,主要改進了兩點:

  • 在當前的 G - M 模型中引入了處理器 P ,增加中間層
  • 在處理器 P 的基礎上實現基于工作竊取的調度器

當前處理器P的本地隊列中如果沒有 G ,就會觸發工作竊取,從其他的 P 的隊列中隨機獲取一些 G 。調度器在調度時會將P的本地隊列的隊列頭的 G 取出來,放到 M 上執行。

任務竊取調度器

1.4.1 G

Goroutine/G 是 Go 語言調度器中待執行的任務,它在運行時調度器的地位與線程在操作系統中差不多,但它占用更小的空間,也極大地降低了上下文切換的開銷。

G 只存在于 Go 語言的運行時,它只是 Go 語言在用戶態提供的線程,作為一種粒度更細的資源調度單元,能夠在高并發的場景下更高效地利用機器的資源。

1.4.2 M

M的數量最多可以創建 10000 個,但其中大多數都不會執行用戶代碼而是陷入系統調用,最多也只有GOMAXPROCS個線程能夠正常運行。

默認情況下,程序運行時會將GOMAXPROCS設置為當前機器的核數。且大多數情況下,我們都會使用 Go 的默認設置,也就是線程數等于 CPU 數。因為默認的設置不會頻繁地觸發操作系統的線程調度和上下文切換,所有的調度都發生在用戶態,由 Go 語言調度器觸發,能減少很多額外開銷。

每個M都有兩個 GG0curgG0是持有調度棧的 Gcurg是當前線程上運行的用戶 G ,這是操作系統唯一關心的兩個 G

G0深度參與 G 的調度,包括 G 的創建、大內存分配和 CGO 函數的執行。

1.4.3 P

Processor/P 是 GM 的中間層,提供 G 需要的上下文環境,也負責調度 P 的等待隊列和全局等待隊列。

通過 P 的調度,每一個 M 都能夠執行多個 G 。當 G 進行 I/O 操作時,P 會讓這個 G 讓出計算資源,提高 M 的利用率。

調度器會在啟動時創建GOMAXPROCS個處理器,所以 P 的數量一定會等于GOMAXPROCS,這些 P 會綁定到不同的 M 上。

1.5 搶占式調度器

因為竊取式調度器存在饑餓和 STW 耗時過長的問題,所以后續又推出了基于協作的搶占式調度器。

1.5.1 基于協作的搶占式調度器

  • 編繹器在調用函數前插入搶占函數(檢查是否需要更多的棧)
  • 運行時會在 GC 的 STW 、系統監控中發現運行時長超過 10ms 的 G ,通用一個字段StackPreempt標記需要搶占的 G
  • 在發生函數調用時,可能會執行編繹器插入的搶占函數,搶占函數會調用創建新棧函數檢查 G 的搶占字段
  • 如果 G 被標記為可搶占,就會觸發搶占讓出當前線程

這種方式雖然增加了運行時的復雜度,但實現相對簡單,沒有帶來額外開銷,因為其是通過編繹器插入函數并通過調度函數作為入口觸發搶占,所以是協作式的搶占。

1.5.2 基于信號的搶占式調度器

協作搶占在一定程度上解決了竊取調度中的問題,但卻產生了嚴重的邊緣問題,非常影響開發體驗[1]

為了改善這些問題,在 1.14 版本中實現了非協作式的搶占式調度,為 G 增加新的狀態和字段并改變原邏輯實現搶占。

  • 啟動程序時,注冊信號處理函數
  • 在 GC 掃描棧時將 G 標記為可搶占,并調用搶占函數,將 G 掛起
  • 搶占函數會向線程發送信號
  • 系統接到信號后會中斷正在運行的線程并執行預先注冊的信息處理函數
  • 根據搶占信號修改當前寄存器,在程序回到用戶態時執行異步搶占
  • 將當前 G 標記為被搶占后讓當前函數陷入休眠并讓出 MP 選擇其他 G 繼續執行

二、調度器運行

2.1 調度器啟動

程序初始化時會創建初始線程 M_0 和主協程 G_0[2],并將 G_0 綁定到 M_0

M_0 是啟動程序后的編號為 0 的主線程,這個 M 對應的實例會在全局變量runtime.m0中,不需要在heap上分配,M0 負責執行初始化操作和啟動第一個 G , 在之后 M_0 就和其他的 M 一樣了。

G_0 是每次啟動一個M都會第一個創建的gourtine,G_0 僅用于負責調度的 GG_0 不指向任何可執行的函數, 每個 M 都會有一個自己的 G_0 。在調度或系統調用時會使用 G_0 的棧空間。

系統初始化完畢后調用調度器初始化,此時會將maxmcount設置為 10000,這是一個 Go 程序能夠創建的最大線程數,雖然可以創建 10000 個線程,但是可以同時運行的 M 還中由GOMAXPROCS變量控制。

從環境變量中獲取了GOMAXPROCS后就會更新程序中 P 的數量,這時整個程序不會執行任何用戶的 GP 也會進入鎖定狀態。

  • P 全局列隊長度如果小于GOMAXPROCS就擴容
  • 遍歷 P 隊列,為隊列中為nil的位置使用new方法創建新的 P ,并調用 P 的初始化函數
  • 通過指針將 M_0 和隊列中的第一個 P 綁定到一起
  • 釋放不再使用的 P
  • 如果全局隊列長度不等于GOMAXPROCS,則截斷全局隊列使其相等[3]
  • 之前只判斷全局隊列小于GOMAXPROCS的情況,所以這里的不相等,就是大于等于GOMAXPROCS
  • 將全局隊列中除第一個 P 之外的所有 P 設置為空閑狀態并加入到全局空閑隊列中

之后調度器會完成相應數量的 P 的啟動,等待用戶創建新的 G 并為 G 調度處理器資源。

2.2 創建 G

想在啟動一個新的 G 來執行任務,需要使用 Go 語言的go關鍵字。

編繹器會根據go后的函數和當前調用程序創建新的 G 結構體并加入 P 的本地運行隊列或者全局運行隊列中。

當本地運行隊列滿時,會將本地隊列中的一部分 G 和待加入的 G 添加到全局運行隊列中。

P 的本地運行隊列最多可以存儲256個 G

2.3 調度循環

調度器啟動后,初始化 G_0 的棧,然后初始化線程并進入調度循環。

2.3.1 獲取 G

  • 為了保證公平,當全局運行隊列中有待執行的 G 時,會有一定幾率從全局隊列中獲取 G 執行
  • 主要還是從 P 的本地運行隊列中獲取 G
  • 如果前兩種方法都沒有獲取到 G ,會通過下面三個方式進行阻塞查找 G
    1. 從本地運行隊列、全局運行隊列中查找
    2. 從網絡輪詢中查找是否有 G 等待執行
    3. 嘗試從其他隨機的的 P 中竊取 G ,還可能竊取其他 P 的計時器

所以當前函數一定會返回一個可執行的 G ,如果實再獲取不到,就會阻塞等待。

2.3.2 執行 G

G_0 將獲取到的 G 調度到當前 M 上執行。

當前的 G 執行完畢后,G_0 會重新執行調度函數觸發新一輪的調度。

Go 語言的運行時調度是一個循環的過程,永不返回。

上面是 G 正常執行的調度過程,但實際上,G 也可能不正常執行,比如阻塞住,此時調度器會斷開當前 MP 的關系,創建或從休眠隊列中獲取一個新的 M 重新與 P 組合。原 MG 會有一個超時時間,如果此期間沒能執行完成,將會釋放 MM 或休眠,或獲取一個空閑的 P 繼續工作,繼續開始新的調度過程。

2.4 線程管理

Go 語言的運行時會通過調度器改變線程的所有權,也提供了 runtime.LockOSThreadruntime.UnlockOSThread 讓我們有能力綁定 Goroutine 和線程完成一些比較特殊的操作。

Goroutine 應該在調用操作系統服務或者依賴線程狀態的非 Go 語言庫時調用 runtime.LockOSThread 函數,例如:C 語言圖形庫等。

當 Goroutine 執行完特殊操作后,會分離 Goroutine 和線程。

在多數情況下,都用不到這一對函數。

2.4.1 線程生命周期

Go 語言的運行時通過 M 執行 P,如果沒有空閑 M 就會創建新的 M

新創建線程只有主動調用退出命令或啟動函數runtime.mstart返回時主動退出。

由此完成線程從創建到銷毀的整個閉環。

2.4.2 自旋線程

當 GMP 組合 P 的本地隊列中沒有 G 時,M 就是自旋線程,自旋線程就會如果[2.3.1](#2.3.1 獲取 G)中所說一直阻塞查找 G

為什么要有自旋線程

自旋本質是保持線程運行,銷毀再創建一個新的線程要消耗大量的CPU資源和時間。

如果創建了一個新的 G ,立即能被自旋線程捕獲,而如果新建線程則不能保持即時性,也就降低了效率。

但自旋線程數量也不宜過多,過多的自旋線程同樣也是浪費CPU資源,所以系統中最多有GOMAXPROCS個自旋線程,通常數量有GOMAXPROCS - 正在運行的非自旋線程數量,多余的線程會休眠或被銷毀。

三、讓GMP可視化

3.1 go tool

go tool trace 記錄了運行時的信息,能提供可視化的 web 頁面。

測試代碼:

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

會得到一個trace.out文件,然后可以用 go 內置的 trace 打開這個文件:

$ go tool trace -http=0.0.0.0:9999 trace.out
2021/03/28 17:29:24 Parsing trace...
2021/03/28 17:29:24 Splitting trace...
2021/03/28 17:29:24 Opening browser. Trace viewer is listening on http://[::]:9999

訪問http://127.0.0.1:9999/trace或你指定的 host:port 可以查看可視化的調度過程。

截屏2021-03-29 18.28.51

3.1.1 G 信息

點擊 Goroutines 對應的那一條藍色矩形,下面的窗口會輸入一些詳細信息

截屏2021-03-29 18.35.54

程序運行過程中一共創建了兩個 G ,其中一個是必須創建的 G_0

所以 G_1 才是 main goroutine ,從可運行狀態變為正在運行,然后程序結束。

3.1.2 M 信息

點擊 Thread 對應的紫色矩形:

截屏2021-03-29 18.41.36

一共有兩個 M ,其中一個是 M_0 ,程序初始化時創建。

3.1.3 P 信息

G_1 中調用的main.main,創建了本次使用的trace G G_2G_1 運行在 P_1 上, G_2 運行在 P_0 上。

3.2 Debug

//trace2.go
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

運行:

$ GODEBUG=schedtrace=1000 ./trace 
SCHED 0ms: gomaxprocs=2 idleprocs=0 threads=5 spinningthreads=1 idlethreads=1 runqueue=0 [0 0]
Hello World
SCHED 1009ms: gomaxprocs=2 idleprocs=2 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0]
Hello World
SCHED 2016ms: gomaxprocs=2 idleprocs=2 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0]
Hello World
SCHED 3022ms: gomaxprocs=2 idleprocs=2 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0]
Hello World
SCHED 4030ms: gomaxprocs=2 idleprocs=2 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0]
Hello World

關鍵字說明:

  • SCHED 調度器的縮寫,標志本行輸入是 Goroutine 調度器的輸出
  • 0ms 從程序啟動到輸出這行日志經過的時間
  • gomaxprocs 最大活躍線程/ P 的數量,與CPU數量一致
  • idleprocs 空閑 P 數量
  • threads 系統線程數量
  • spinningthreads 自旋線程數量
  • idlethreads 空閑線程數量
  • runqueue 調度器全局隊列中 G 的數量
  • [0 0] 分別為2個 P 的本地隊列中 G 的數量

注釋:


  1. https://github.com/golang/go/issues/24543 ?

  2. https://github.com/golang/go/blob/master/src/runtime/proc.go#L84 ?

  3. https://github.com/golang/go/blob/master/src/runtime/proc.go#L4805 ?

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容