[典藏版]Golang調度器GMP原理與調度全分析

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

第一章 Golang調度器的由來git

第二章 Goroutine調度器的GMP模型及設計思想程序員

第三章 Goroutine調度場景過程全圖文解析github

1、Golang「調度器」的由來?

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

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

一切的程序只能串行發生。
5-單進程操做系統.png算法

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

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

2.進程阻塞所帶來的CPU時間浪費。瀏覽器

那麼能不能有多個進程來宏觀一塊兒來執行多個任務呢?bash

後來操做系統就具備了最先的併發能力:多進程併發,當一個進程阻塞的時候,切換到另外等待執行的進程,這樣就能儘可能把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之間的切換)致使頻繁的線程阻塞和取消阻塞操做增長了系統開銷。

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

一、P的數量:

  • 由啓動時環境變量$GOMAXPROCS或者是由runtime的方法GOMAXPROCS()決定。這意味着在程序執行的任意時刻都只有$GOMAXPROCS個goroutine在同時運行。

二、M的數量:

  • go語言自己的限制:go程序啓動時,會設置M的最大數量,默認10000.可是內核很難支持這麼多的線程數,因此這個限制能夠忽略。
  • runtime/debug中的SetMaxThreads函數,設置M的最大數量
  • 一個M阻塞了,會建立新的M。

M與P的數量沒有絕對關係,一個M阻塞,P就會去建立或者切換另外一個M,因此,即便P的默認數量是1,也有可能會建立不少個M出來。

P和M什麼時候會被建立

一、P什麼時候建立:在肯定了P的最大數量n後,運行時系統會根據這個數量建立n個P。

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

從上圖咱們能夠分析出幾個結論:

​ 一、咱們經過 go func()來建立一個goroutine;

​ 二、有兩個存儲G的隊列,一個是局部調度器P的本地隊列、一個是全局G隊列。新建立的G會先保存在P的本地隊列中,若是P的本地隊列已經滿了就會保存在全局的隊列中;

​ 三、G只能運行在M中,一個M必須持有一個P,M與P是1:1的關係。M會從P的本地隊列彈出一個可執行狀態的G來執行,若是P的本地隊列爲空,就會想其餘的MP組合偷取一個可執行的G來執行;

​ 四、一個M調度G執行的過程是一個循環機制;

​ 五、當M執行某一個G時候若是發生了syscall或則其他阻塞操做,M會阻塞,若是當前有一些G在執行,runtime會把這個線程M從P中摘除(detach),而後再建立一個新的操做系統的線程(若是有空閒的線程可用就複用空閒線程)來服務於這個P;

​ 六、當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調度原理的一些場景問題。

3、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已經把G七、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本地隊列G五、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回收銷燬)。


4、小結

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


文章推薦

開源軟件做品

(原創開源)Zinx-基於Golang輕量級服務器併發框架-完整版(附教程視頻)
(原創開源)Lars-基於C++負載均衡遠程調度系統-完整版

精選文章

使用Golang的interface接口設計原則
深刻淺出Golang的協程池設計
Go語言構建微服務一站式解決方案


關於做者:

做者:Aceld(劉丹冰)

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

創做不易, 歡迎關注做者, 共同窗習進步

微信公衆號「劉丹冰Aceld」

相關文章
相關標籤/搜索