[典藏版] Golang 排程器 GMP 原理與排程全分析

Aceld發表於2020-03-11

該文章主要詳細具體的介紹Goroutine排程器過程及原理,可以對Go排程器的詳細排程過程有一個清晰的理解,花 費4天時間作了30+張圖(推薦收藏),包括如下幾個章節。

第一章 Golang排程器的由來

第二章 Goroutine排程器的GMP模型及設計思想

第三章 Goroutine排程場景過程全圖文解析

一、Golang“排程器”的由來?

(1) 單程式時代不需要排程器

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

一切的程式只能序列發生。
5-單程式作業系統.png

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

1.單一的執行流程,計算機只能一個任務一個任務處理。

2.程式阻塞所帶來的CPU時間浪費。

那麼能不能有多個程式來巨集觀一起來執行多個任務呢?

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

(2)多程式/執行緒時代有了排程器需求

6-多程式作業系統.png

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

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

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

但是對於Linux作業系統來講,cpu對程式的態度和執行緒的態度是一樣的。
7-cpu切換浪費成本.png

很明顯,CPU排程切換的是程式和執行緒。儘管執行緒看起來很美好,但實際上多執行緒開發設計會變得更加複雜,要考慮很多同步競爭等問題,如鎖、競爭衝突等。

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

多程式、多執行緒已經提高了系統的併發能力,但是在當今網際網路高併發場景下,為每個任務都建立一個執行緒是不現實的,因為會消耗大量的記憶體(程式虛擬記憶體會佔用4GB[32位作業系統], 而執行緒也要大約4MB)。

大量的程式/執行緒出現了新的問題

  • 高記憶體佔用
  • 排程的高消耗CPU

好了,然後工程師們就發現,其實一個執行緒分為“核心態“執行緒和”使用者態“執行緒。

一個“使用者態執行緒”必須要繫結一個“核心態執行緒”,但是CPU並不知道有“使用者態執行緒”的存在,它只知道它執行的是一個“核心態執行緒”(Linux的PCB程式控制塊)。

8-執行緒的核心和使用者態.png

這樣,我們再去細化去分類一下,核心執行緒依然叫“執行緒(thread)”,使用者執行緒叫“協程(co-routine)”.

9-協程和執行緒.png

​ 看到這裡,我們就要開腦洞了,既然一個協程(co-routine)可以繫結一個執行緒(thread),那麼能不能多個協程(co-routine)繫結一個或者多個執行緒(thread)上呢。

​ 之後,我們就看到了有3中協程和執行緒的對映關係:

N:1關係

N個協程繫結1個執行緒,優點就是協程在使用者態執行緒即完成切換,不會陷入到核心態,這種切換非常的輕量快速。但也有很大的缺點,1個程式的所有協程都繫結在1個執行緒上

缺點:

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

10-N-1關係.png

1:1 關係

1個協程繫結1個執行緒,這種最容易實現。協程的排程都由CPU完成了,不存在N:1缺點,

缺點:

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

11-1-1.png

M:N關係

M個協程繫結1個執行緒,是N:1和1:1型別的結合,克服了以上2種模型的缺點,但實現起來最為複雜。

12-m-n.png

​ 協程跟執行緒是有區別的,執行緒由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來表示執行緒,那麼我們也會用這種表達的對應關係。

13-gm.png

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

14-old排程器.png

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

老排程器有幾個缺點:

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

二、Goroutine排程器的GMP模型的設計思想

面對之前排程器的問題,Go設計了新的排程器。

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

15-gmp.png

Processor,它包含了執行goroutine的資源,如果執行緒想執行goroutine,必須先獲取P,P中還包含了可執行的G佇列。

(1)GMP模型

在Go中,執行緒是執行goroutine的實體,排程器的功能是把可執行的goroutine分配到工作執行緒上

16-GMP-排程.png

  1. 全域性佇列(Global Queue):存放等待執行的G。
  2. P的本地佇列:同全域性佇列類似,存放的也是等待執行的G,存的數量有限,不超過256個。新建G’時,G’優先加入到P的本地佇列,如果佇列滿了,則會把本地佇列中一半的G移動到全域性佇列。
  3. P列表:所有的P都在程式啟動時建立,並儲存在陣列中,最多有GOMAXPROCS(可配置)個。
  4. 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() 排程流程

18-go-func排程週期.jpeg

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

​ 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)排程器的生命週期

17-pic-go排程器生命週期.png

特殊的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.mainruntime中也有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 能夠看見視覺化的排程流程。

19-go-trace1.png

20-go-trace2.png

G資訊

點選Goroutines那一行視覺化的資料條,我們會看到一些詳細的資訊。

20-go-trace3.png

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

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

M資訊

點選Threads那一行視覺化的資料條,我們會看到一些詳細的資訊。

22-go-trace4.png

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

P資訊
23-go-trace5.png

G1中呼叫了main.main,建立了trace goroutine g18。G1執行在P1上,G18執行在P0上。

這裡有兩個P,我們知道,一個P必須繫結一個M才能排程G。

我們在來看看上面的M資訊。

24-go-trace6.png

我們會發現,確實G18在P0上被執行的時候,確實在Threads行多了一個M的資料,點選檢視如下:

25-go-trace7.png

多了一個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的數量。

下一篇,我們來繼續詳細的分析GMP排程原理的一些場景問題。

三、Go排程器排程場景過程全解析

(1)場景1

P擁有G1,M1獲取P後開始執行G1,G1使用go func()建立了G2,為了區域性性G2優先加入到P1的本地佇列。
26-gmp場景1.png


(2)場景2

G1執行完成後(函式:goexit),M上執行的goroutine切換為G0,G0負責排程時協程的切換(函式:schedule)。從P的本地佇列取G2,從G0切換到G2,並開始執行G2(函式:execute)。實現了執行緒M1的複用。
27-gmp場景2.png


(3)場景3

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

28-gmp場景3.png


(4)場景4

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

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

29-gmp場景4.png
這些G被轉移到全域性佇列時,會被打亂順序。所以G3,G4,G7被轉移到全域性佇列。


(5)場景5

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

30-gmp場景5.png

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


(6)場景6

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

31-gmp場景6.png

假定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本地佇列的負載均衡

32-gmp場景7.001.jpeg

假定我們場景中一共有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圖的左半部分。

33-gmp場景8.png

全域性佇列已經沒有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。

34-gmp場景9.png

為什麼要讓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繫結。

35-gmp場景10.png

(11)場景11

G8建立了G9,假如G8進行了非阻塞系統呼叫
36-gmp場景11.png

​ M2和P2會解綁,但M2會記住P2,然後G8和M2進入系統呼叫狀態。當G8和M2退出系統呼叫時,會嘗試獲取P2,如果無法獲取,則獲取空閒的P,如果依然沒有,G8會被記為可執行狀態,並加入到全域性佇列,M2因為沒有P的繫結而變成休眠狀態(長時間休眠等待GC回收銷燬)。


四、小結

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


###關於作者:

作者:Aceld(劉丹冰)

mail: danbing.at@gmail.com
github: https://github.com/aceld
原創書籍gitbook: http://legacy.gitbook.com/@aceld

創作不易, 共同學習進步, 歡迎關注作者, 回覆”zinx”有好禮

作者微信公眾號


本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章