Go語言基礎之GMP原理與調度


Go語言基礎之GMP原理與調度

一、Golang “調度器” 的由來?

(1) 單進程時代不需要調度器

我們知道,一切的軟件都是跑在操作系統上,真正用來干活 (計算) 的是 CPU。早期的操作系統每個程序就是一個進程,知道一個程序運行完,才能進行下一個進程,就是 “單進程時代”

一切的程序只能串行發生。

img

早期的單進程操作系統,面臨 2 個問題:

  1. 單一的執行流程,計算機只能一個任務一個任務處理。
  2. 進程阻塞所帶來的 CPU 時間浪費。

那么能不能有多個進程來宏觀一起來執行多個任務呢?

后來操作系統就具有了最早的並發能力:多進程並發,當一個進程阻塞的時候,切換到另外等待執行的進程,這樣就能盡量把 CPU 利用起來,CPU 就不浪費了。

(2) 多進程 / 線程時代有了調度器需求

img

在多進程 / 多線程的操作系統中,就解決了阻塞的問題,因為一個進程阻塞 cpu 可以立刻切換到其他進程中去執行,而且調度 cpu 的算法可以保證在運行的進程都可以被分配到 cpu 的運行時間片。這樣從宏觀來看,似乎多個進程是在同時被運行。

但新的問題就又出現了,進程擁有太多的資源,進程的創建、切換、銷毀,都會占用很長的時間,CPU 雖然利用起來了,但如果進程過多,CPU 有很大的一部分都被用來進行進程調度了。

怎么才能提高 CPU 的利用率呢?

但是對於 Linux 操作系統來講,cpu 對進程的態度和線程的態度是一樣的。

img

很明顯,CPU 調度切換的是進程和線程。盡管線程看起來很美好,但實際上多線程開發設計會變得更加復雜,要考慮很多同步競爭等問題,如鎖、競爭沖突等。

(3) 協程來提高 CPU 利用率

多進程、多線程已經提高了系統的並發能力,但是在當今互聯網高並發場景下,為每個任務都創建一個線程是不現實的,因為會消耗大量的內存 (進程虛擬內存會占用 4GB [32 位操作系統], 而線程也要大約 4MB)。

大量的進程 / 線程出現了新的問題

  • 高內存占用
  • 調度的高消耗 CPU

好了,然后工程師們就發現,其實一個線程分為 “內核態 “線程和” 用戶態 “線程。

一個 “用戶態線程” 必須要綁定一個 “內核態線程”,但是 CPU 並不知道有 “用戶態線程” 的存在,它只知道它運行的是一個 “內核態線程”(Linux 的 PCB 進程控制塊)。

img

這樣,我們再去細化去分類一下,內核線程依然叫 “線程 (thread)”,用戶線程叫 “協程 (co-routine)”.

img

看到這里,我們就要開腦洞了,既然一個協程 (co-routine) 可以綁定一個線程 (thread),那么能不能多個協程 (co-routine) 綁定一個或者多個線程 (thread) 上呢。

之后,我們就看到了有 3 中協程和線程的映射關系:

N:1 關系

N 個協程綁定 1 個線程,優點就是協程在用戶態線程即完成切換,不會陷入到內核態,這種切換非常的輕量快速。但也有很大的缺點,1 個進程的所有協程都綁定在 1 個線程上

缺點:

  • 某個程序用不了硬件的多核加速能力
  • 一旦某協程阻塞,造成線程阻塞,本進程的其他協程都無法執行了,根本就沒有並發的能力了。

img

1:1 關系

1 個協程綁定 1 個線程,這種最容易實現。協程的調度都由 CPU 完成了,不存在 N:1 缺點,

缺點:

  • 協程的創建、刪除和切換的代價都由 CPU 完成,有點略顯昂貴了。

img

M:N 關系

M 個協程綁定 1 個線程,是 N:1 和 1:1 類型的結合,克服了以上 2 種模型的缺點,但實現起來最為復雜。

img

協程跟線程是有區別的,線程由 CPU 調度是搶占式的,協程由用戶態調度是協作式的,一個協程讓出 CPU 后,才執行下一個協程。

(4) Go 語言的協程 goroutine

Go 為了提供更容易使用的並發方法,使用了 goroutine 和 channel。goroutine 來自協程的概念,讓一組可復用的函數運行在一組線程之上,即使有協程阻塞,該線程的其他協程也可以被 runtime 調度,轉移到其他可運行的線程上。最關鍵的是,程序員看不到這些底層的細節,這就降低了編程的難度,提供了更容易的並發。

Go 中,協程被稱為 goroutine,它非常輕量,一個 goroutine 只占幾 KB,並且這幾 KB 就足夠 goroutine 運行完,這就能在有限的內存空間內支持大量 goroutine,支持了更多的並發。雖然一個 goroutine 的棧只占幾 KB,但實際是可伸縮的,如果需要更多內容,runtime 會自動為 goroutine 分配。

Goroutine 特點:

  • 占用內存更小(幾 kb)
  • 調度更靈活 (runtime 調度)

(5) 被廢棄的 goroutine 調度器

好了,既然我們知道了協程和線程的關系,那么最關鍵的一點就是調度協程的調度器的實現了。

Go 目前使用的調度器是 2012 年重新設計的,因為之前的調度器性能存在問題,所以使用 4 年就被廢棄了,那么我們先來分析一下被廢棄的調度器是如何運作的?

大部分文章都是會用 G 來表示 Goroutine,用 M 來表示線程,那么我們也會用這種表達的對應關系。

img

下面我們來看看被廢棄的 golang 調度器是如何實現的?

img

M 想要執行、放回 G 都必須訪問全局 G 隊列,並且 M 有多個,即多線程訪問同一資源需要加鎖進行保證互斥 / 同步,所以全局 G 隊列是有互斥鎖進行保護的。

老調度器有幾個缺點:

  • 創建、銷毀、調度 G 都需要每個 M 獲取鎖,這就形成了激烈的鎖競爭。
  • M 轉移 G 會造成延遲和額外的系統負載。比如當 G 中包含創建新協程的時候,M 創建了 G’,為了繼續執行 G,需要把 G’交給 M’執行,也造成了很差的局部性,因為 G’和 G 是相關的,最好放在 M 上執行,而不是其他 M’。
  • 系統調用 (CPU 在 M 之間的切換) 導致頻繁的線程阻塞和取消阻塞操作增加了系統開銷。

二、Goroutine 調度器的 GMP 模型的設計思想

面對之前調度器的問題,Go 設計了新的調度器。

在新調度器中,出列 M (thread) 和 G (goroutine),又引進了 P (Processor)。

img

Processor,它包含了運行 goroutine 的資源,如果線程想運行 goroutine,必須先獲取 P,P 中還包含了可運行的 G 隊列。

(1) GMP 模型

在 Go 中,線程是運行 goroutine 的實體,調度器的功能是把可運行的 goroutine 分配到工作線程上。

img

  • 全局隊列(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 的核上執行。

有關 P 和 M 的個數問題

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 出來。

P 和 M 何時會被創建

1、P 何時創建:在確定了 P 的最大數量 n 后,運行時系統會根據這個數量創建 n 個 P。

2、M 何時創建:沒有足夠的 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 隊列:在新的調度器中依然有全局 G 隊列,但功能已經被弱化了,當 M 執行 work stealing 從其他 P 偷不到 G 時,它可以從全局 G 隊列獲取 G。

(3) go func () 調度流程

img

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

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) 調度器的生命周期

img

特殊的 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。

我們來跟蹤一段代碼

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 的數據。

方式 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 
2020/02/23 10:44:11 Parsing trace...
2020/02/23 10:44:11 Splitting trace...
2020/02/23 10:44:11 Opening browser. Trace viewer is listening on http://127.0.0.1:33479

我們可以通過瀏覽器打開 http://127.0.0.1:33479 網址,點擊 view trace 能夠看見可視化的調度流程。

img

img

G 信息

點擊 Goroutines 那一行可視化的數據條,我們會看到一些詳細的信息。

img

一共有兩個G在程序中,一個是特殊的G0,是每個M必須有的一個初始化的G,這個我們不必討論。

其中 G1 應該就是 main goroutine (執行 main 函數的協程),在一段時間內處於可運行和運行的狀態。

M 信息

點擊 Threads 那一行可視化的數據條,我們會看到一些詳細的信息。

img

一共有兩個 M 在程序中,一個是特殊的 M0,用於初始化使用,這個我們不必討論。

P 信息

img

G1 中調用了 main.main,創建了 trace goroutine g18。G1 運行在 P1 上,G18 運行在 P0 上。

這里有兩個 P,我們知道,一個 P 必須綁定一個 M 才能調度 G。

我們在來看看上面的 M 信息。

img

我們會發現,確實 G18 在 P0 上被運行的時候,確實在 Threads 行多了一個 M 的數據,點擊查看如下:

img

多了一個 M2 應該就是 P0 為了執行 G18 而動態創建的 M2.

方式 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 的數量。

三、Go 調度器調度場景過程全解析

(1) 場景 1

P 擁有 G1,M1 獲取 P 后開始運行 G1,G1 使用 go func() 創建了 G2,為了局部性 G2 優先加入到 P1 的本地隊列。

img

(2) 場景 2

G1 運行完成后 (函數:goexit),M 上運行的 goroutine 切換為 G0,G0 負責調度時協程的切換(函數:schedule)。從 P 的本地隊列取 G2,從 G0 切換到 G2,並開始運行 G2 (函數:execute)。實現了線程 M1 的復用。

img

(3) 場景 3

假設每個 P 的本地隊列只能存 3 個 G。G2 要創建了 6 個 G,前 3 個 G(G3, G4, G5)已經加入 p1 的本地隊列,p1 本地隊列滿了。

img

(4) 場景 4

G2 在創建 G7 的時候,發現 P1 的本地隊列已滿,需要執行負載均衡 (把 P1 中本地隊列中前一半的 G,還有新創建 G 轉移到全局隊列)

(實現中並不一定是新的 G,如果 G 是 G2 之后就執行的,會被保存在本地隊列,利用某個老的 G 替換新 G 加入全局隊列)

img

這些 G 被轉移到全局隊列時,會被打亂順序。所以 G3,G4,G7 被轉移到全局隊列。

(5) 場景 5

G2 創建 G8 時,P1 的本地隊列未滿,所以 G8 會被加入到 P1 的本地隊列。

img

G8 加入到 P1 點本地隊列的原因還是因為 P1 此時在與 M1 綁定,而 G2 此時是 M1 在執行。所以 G2 創建的新的 G 會優先放置到自己的 M 綁定的 P 上。

(6) 場景 6

規定:在創建 G 時,運行的 G 會嘗試喚醒其他空閑的 P 和 M 組合去執行。

img

假定 G2 喚醒了 M2,M2 綁定了 P2,並運行 G0,但 P2 本地隊列沒有 G,M2 此時為自旋線程(沒有 G 但為運行狀態的線程,不斷尋找 G)。

(7) 場景 7

M2 嘗試從全局隊列 (簡稱 “GQ”) 取一批 G 放到 P2 的本地隊列(函數:findrunnable())。M2 從全局隊列取的 G 數量符合下面的公式:

n = min(len(GQ)/GOMAXPROCS + 1, len(GQ/2))

至少從全局隊列取 1 個 g,但每次不要從全局隊列移動太多的 g 到 p 本地隊列,給其他 p 留點。這是從全局隊列到 P 本地隊列的負載均衡。

img

假定我們場景中一共有 4 個 P(GOMAXPROCS 設置為 4,那么我們允許最多就能用 4 個 P 來供 M 使用)。所以 M2 只從能從全局隊列取 1 個 G(即 G3)移動 P2 本地隊列,然后完成從 G0 到 G3 的切換,運行 G3。

(8) 場景 8

假設 G2 一直在 M1 上運行,經過 2 輪后,M2 已經把 G7、G4 從全局隊列獲取到了 P2 的本地隊列並完成運行,全局隊列和 P2 的本地隊列都空了,如場景 8 圖的左半部分。

img

全局隊列已經沒有 G,那 m 就要執行 work stealing (偷取):從其他有 G 的 P 哪里偷取一半 G 過來,放到自己的 P 本地隊列。P2 從 P1 的本地隊列尾部取一半的 G,本例中一半則只有 1 個 G8,放到 P2 的本地隊列並執行。

(9) 場景 9

G1 本地隊列 G5、G6 已經被其他 M 偷走並運行完成,當前 M1 和 M2 分別在運行 G2 和 G8,M3 和 M4 沒有 goroutine 可以運行,M3 和 M4 處於自旋狀態,它們不斷尋找 goroutine。

img

為什么要讓 m3 和 m4 自旋,自旋本質是在運行,線程在運行卻沒有執行 G,就變成了浪費 CPU. 為什么不銷毀現場,來節約 CPU 資源。因為創建和銷毀 CPU 也會浪費時間,我們希望當有新 goroutine 創建時,立刻能有 M 運行它,如果銷毀再新建就增加了時延,降低了效率。當然也考慮了過多的自旋線程是浪費 CPU,所以系統中最多有 GOMAXPROCS 個自旋的線程 (當前例子中的 GOMAXPROCS=4,所以一共 4 個 P),多余的沒事做線程會讓他們休眠。

(10) 場景 10

假定當前除了 M3 和 M4 為自旋線程,還有 M5 和 M6 為空閑的線程 (沒有得到 P 的綁定,注意我們這里最多就只能夠存在 4 個 P,所以 P 的數量應該永遠是 M>=P, 大部分都是 M 在搶占需要運行的 P),G8 創建了 G9,G8 進行了阻塞的系統調用,M2 和 P2 立即解綁,P2 會執行以下判斷:如果 P2 本地隊列有 G、全局隊列有 G 或有空閑的 M,P2 都會立馬喚醒 1 個 M 和它綁定,否則 P2 則會加入到空閑 P 列表,等待 M 來獲取可用的 p。本場景中,P2 本地隊列有 G9,可以和其他空閑的線程 M5 綁定。

img

(11) 場景 11

G8 創建了 G9,假如 G8 進行了非阻塞系統調用。

img

M2 和 P2 會解綁,但 M2 會記住 P2,然后 G8 和 M2 進入系統調用狀態。當 G8 和 M2 退出系統調用時,會嘗試獲取 P2,如果無法獲取,則獲取空閑的 P,如果依然沒有,G8 會被記為可運行狀態,並加入到全局隊列,M2 因為沒有 P 的綁定而變成休眠狀態 (長時間休眠等待 GC 回收銷毀)。

四、小結

總結,Go 調度器很輕量也很簡單,足以撐起 goroutine 的調度工作,並且讓 Go 具有了原生(強大)並發的能力。Go 調度本質是把大量的 goroutine 分配到少量線程上去執行,並利用多核並行,實現更強大的並發。

轉自公眾號:劉丹冰Aceld


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM