1. 程式人生 > 實用技巧 >Go語言的排程模型(GPM)

Go語言的排程模型(GPM)

GPM模型

定義於src/runtime/runtime2.go

  • G: Gourtines(攜帶任務), 每個Goroutine對應一個G結構體,G儲存Goroutine的執行堆疊,即併發任務狀態。G並非執行體,每個G需要繫結到P才能被排程執行。
  • P: Processors(分配任務), 對G來說,P相當於CPU核,G只有繫結到P(在P的local runq中)才能被排程。對M來說,P提供了相關的執行環境(Context),如記憶體分配狀態(mcache),任務佇列(G)等
  • M: Machine(尋找任務), OS執行緒抽象,負責排程任務,和某個P繫結,從P的runq中不斷取出G,切換堆疊並執行,M本身不具備執行狀態,在需要任務切換時,M將堆疊狀態寫回G,任何其它M都能據此恢復執行。

G-P-M模型示意圖:

PS:

  1. P的個數由GOMAXPROCS指定,是固定的,因此限制最大併發數
  2. M的個數是不定的,由Go Runtime調整,預設最大限制為10000個

基本排程過程:

  1. 建立一個 G 物件;
  2. 將 G 儲存至 P中;
  3. P 去喚醒(告訴)一個 M,然後繼續執行它的執行序(分配下一個 G);
  4. M 尋找空閒的 P,讀取該 P 要分配的 G;
  5. 接下來 M 執行一個排程迴圈,呼叫 G → 執行 → 清理執行緒 → 繼續找新的 G 執行。

各自攜帶的資訊:

  • G

    • 需執行函式的指令(指標)
    • 執行緒上下文的資訊(goroutine切換時,用於儲存 g 的上下文,例如,變數、相關資訊等)
    • 現場保護和現場恢復(用於全域性佇列執行時的保護)
    • 所屬的函式棧
    • 當前執行的 m
    • 被阻塞的時間
  • P,P/M需要進行繫結,構成一個執行單元。P決定了同時可以併發任務的數量,可通過GOMAXPROCS限制同時執行使用者級任務的作業系統執行緒。可以通過runtime.GOMAXPROCS進行指定。

    • 狀態(空閒、執行...)
    • 關聯的 m
    • 可執行的 goroutine 的佇列
    • 下一個 g
  • M,所有M是有執行緒棧的。如果不對該執行緒棧提供記憶體的話,系統會給該執行緒棧提供記憶體(不同作業系統提供的執行緒棧大小不同)。

    • 所屬的排程棧
    • 當前執行的 g
    • 關聯的 p
    • 狀態

基礎知識:

普通棧:普通棧指的是需要排程的 goroutine 組成的函式棧,是可增長的棧,因為 goroutine 可以越開越多。

執行緒棧:執行緒棧是由需要將 goroutine 放置執行緒上的 m 們組成,實質上 m 也是由 goroutine 生成的,執行緒棧大小固定(設定了 m 的數量)。所有排程相關的程式碼,會先切換到該goroutine的棧中再執行。也就是說執行緒的棧也是用的g實現,而不是使用的OS的。

全域性佇列:該佇列儲存的 G 將被所有的 M 全域性共享,為保證資料競爭問題,需加鎖處理。

本地佇列:該佇列儲存資料資源相同的任務,每個本地佇列都會繫結一個 M ,指定其完成任務,沒有資料競爭,無需加鎖處理,處理速度遠高於全域性佇列。

上下文切換:對於程式碼中某個值說,上下文是指這個值所在的區域性(全域性)作用域物件。相對於程序而言,上下文就是程序執行時的環境,具體來說就是各個變數和資料,包括所有的暫存器變數、程序開啟的檔案、記憶體(堆疊)資訊等。

執行緒清理:

由於每個P都需要繫結一個 M 進行任務執行,所以當清理執行緒的時候,只需要將 P 釋放(解除繫結)(M就沒有任務),即可。P 被釋放主要由兩種情況:

  • 主動釋放:最典型的例子是,當執行G任務時有系統呼叫,當發生系統呼叫時M會處於阻塞狀態。排程器會設定一個超時時間,當超時時會將P釋放。
  • 被動釋放:如果發生系統呼叫,有一個專門監控程式,進行掃描當前處於阻塞的P/M組合。當超過系統程式設定的超時時間,會自動將P資源搶走。去執行佇列的其它G任務。
阻塞是正在執行的執行緒沒有執行結束,暫時讓出 CPU。

搶佔式排程:

runtime.main中會建立一個額外m執行sysmon函式,搶佔就是在sysmon中實現的。

sysmon會進入一個無限迴圈, 第一輪迴休眠20us, 之後每次休眠時間倍增, 最終每一輪都會休眠10ms. sysmon中有netpool(獲取fd事件), retake(搶佔), forcegc(按時間強制執行gc), scavenge heap(釋放自由列表中多餘的項減少記憶體佔用)等處理。

搶佔條件:

  1. 如果 P 在系統呼叫中,且時長已經過一次 sysmon 後,則搶佔;

呼叫handoffp解除 M 和 P 的關聯。

  1. 如果 P 在執行,且時長經過一次 sysmon 後,並且時長超過設定的阻塞時長,則搶佔;

設定標識,標識該函式可以被中止,當呼叫棧識別到這個標識時,就知道這是搶佔觸發的, 這時會再檢查一遍是否要搶佔。

流程:

每創建出一個 g,優先建立一個 p 進行儲存,當 p 達到限制後,則加入狀態為 waiting 的佇列中。

如果 g 執行時需要被阻塞,則會進行上下文切換,系統歸還資源後,再返回繼續執行。

當一個G長久阻塞在一個M上時,runtime會新建一個M,阻塞G所在的P會把其他的G 掛載在新建的M上。當舊的G阻塞完成或者認為其已經死掉時 回收舊的M(搶佔式排程)。

P會對自己管理的goroutine佇列做一些排程(比如把佔用CPU時間較長的goroutine暫停、執行後續的goroutine等等)當自己的佇列消費完了就去全域性佇列裡取,如果全域性佇列裡也消費完了會去其他P的佇列裡搶任務(所以需要單獨儲存下一個 g 的地址,而不是從佇列裡獲取)。

總結:


  Go比較優勢的設計就是P上下文這個概念的出現,如果只有G和M的對應關係,那麼當G阻塞在IO上的時候,M是沒有實際在工作的,這樣造成了資源的浪費,沒有了P,那麼所有G的列表都放在全域性,這樣導致臨界區太大,對多核排程造成極大影響。

  保護現場的搶佔式排程和G被阻塞後傳遞給其他m呼叫的核心思想,使得goroutine的產生。

  從執行緒排程講,Go語言相比起其他語言的優勢在於OS執行緒是由OS核心來排程的,goroutine則是由Go執行時(runtime)自己的排程器排程的,這個排程器使用一個稱為m:n排程的技術(複用/排程m個goroutine到n個OS執行緒)。 其一大特點是goroutine的排程是在使用者態下完成的, 不涉及核心態與使用者態之間的頻繁切換,包括記憶體的分配與釋放,都是在使用者態維護著一塊大的記憶體池, 不直接呼叫系統的malloc函式(除非記憶體池需要改變),成本比排程OS執行緒低很多。 另一方面充分利用了多核的硬體資源,近似的把若干goroutine均分在物理執行緒上, 再加上本身goroutine的超輕量,以上種種保證了go排程方面的效能。

———————————————————————————————————————————————————————————————————————————

原始碼附註:

排程流程

  在M與P繫結後,M會不斷從P的Local佇列(runq)中取出G(無鎖操作),切換到G的堆疊並執行,當P的Local佇列中沒有G時,再從Global佇列中返回一個G(有鎖操作,因此實際還會從Global佇列批量轉移一批G到P Local佇列),當Global佇列中也沒有待執行的G時,則嘗試從其它的P竊取(steal)部分G來執行,原始碼如下:

// go1.9.1  src/runtime/proc.go
// 省略了GC檢查等其它細節,只保留了主要流程

// g:       G結構體定義
// sched:   Global佇列
// 獲取一個待執行的G
func findrunnable() (gp *g, inheritTime bool) {
    // 獲取當前的G物件
    _g_ := getg()

top:
    // 獲取當前P物件
    _p_ := _g_.m.p.ptr()

    // 1. 嘗試從P的Local佇列中取得G 優先_p_.runnext 然後再從Local佇列中取
    if gp, inheritTime := runqget(_p_); gp != nil {
        return gp, inheritTime
    }

    // 2. 嘗試從Global佇列中取得G
    if sched.runqsize != 0 {
        lock(&sched.lock)
        // globrunqget從Global佇列中獲取G 並轉移一批G到_p_的Local佇列
        gp := globrunqget(_p_, 0)
        unlock(&sched.lock)
        if gp != nil {
            return gp, false
        }
    }

    // 3. 檢查netpoll任務
    if netpollinited() && sched.lastpoll != 0 {
        if gp := netpoll(false); gp != nil { // non-blocking
            // netpoll返回的是G連結串列,將其它G放回Global佇列
            injectglist(gp.schedlink.ptr())
            casgstatus(gp, _Gwaiting, _Grunnable)
            if trace.enabled {
                traceGoUnpark(gp, 0)
            }
            return gp, false
        }
    }

    // 4. 嘗試從其它P竊取任務
    procs := uint32(gomaxprocs)
    if atomic.Load(&sched.npidle) == procs-1 {
        goto stop
    }
    if !_g_.m.spinning {
        _g_.m.spinning = true
        atomic.Xadd(&sched.nmspinning, 1)
    }
    for i := 0; i < 4; i++ {
        // 隨機P的遍歷順序
        for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
            if sched.gcwaiting != 0 {
                goto top
            }
            stealRunNextG := i > 2 // first look for ready queues with more than 1 g
            // runqsteal執行實際的steal工作,從目標P的Local佇列轉移一般的G過來
            // stealRunNextG指是否steal目標P的p.runnext G
            if gp := runqsteal(_p_, allp[enum.position()], stealRunNextG); gp != nil {
                return gp, false
            }
        }
    }
    ...
}

  當無G可執行時,M會與P解綁,進入休眠狀態

使用者態阻塞/喚醒

  當Goroutine因為Channel操作而阻塞(通過gopark)時,對應的G會被放置到某個wait佇列(如channel的waitq),該G的狀態由_Gruning變為_Gwaitting,而M會跳過該G嘗試獲取並執行下一個G。

  當阻塞的G被G2喚醒(通過goready)時(比如channel可讀/寫),G會嘗試加入G2所在P的runnext,然後再是P Local佇列和Global佇列。

SYSCALL

  當G被阻塞在某個系統呼叫上時,此時G會阻塞在_Gsyscall狀態,M也處於block on syscall狀態,此時仍然可被搶佔排程: 執行該G的M會與P解綁,而P則嘗試與其它idle的M繫結,繼續執行其它G。如果沒有其它idle的M,但佇列中仍然有G需要執行,則建立一個新的M。

  當系統呼叫完成後,G會重新嘗試獲取一個idle的P,並恢復執行,如果沒有idle的P,G將加入到Global佇列。

  

系統呼叫能被排程的關鍵有兩點:

 runtime/syscall包中,將系統呼叫分為SysCall和RawSysCall,前者和後者的區別是前者會在系統呼叫前後分別呼叫entersyscall和exitsyscall(位於src/runtime/proc.go),做一些現場儲存和恢復操作,這樣才能使P安全地與M解綁,並在其它M上繼續執行其它G。某些系統呼叫本身可以確定會長時間阻塞(比如鎖),會呼叫entersyscallblock在發起系統呼叫前直接讓P和M解綁(handoffp)。

  另一個是sysmon,它負責檢查所有系統呼叫的執行時間,判斷是否需要handoffp。

sysmon

  sysmon是一個由runtime啟動的M,也叫監控執行緒,它無需P也可以執行,它每20us~10ms喚醒一次,主要執行:

  1. 釋放閒置超過5分鐘的span實體記憶體;
  2. 如果超過2分鐘沒有垃圾回收,強制執行;
  3. 將長時間未處理的netpoll結果新增到任務佇列;
  4. 向長時間執行的G任務發出搶佔排程;
  5. 收回因syscall長時間阻塞的P;

搶佔式排程

  當某個goroutine執行超過10ms,sysmon會向其發起搶佔排程請求,由於Go排程不像OS排程那樣有時間片的概念,因此實際搶佔機制要弱很多: Go中的搶佔實際上是為G設定搶佔標記(g.stackguard0),當G呼叫某函式時(更確切說,在通過newstack分配函式棧時),被編譯器安插的指令會檢查這個標記,並且將當前G以runtime.Goched的方式暫停,並加入到全域性佇列。

NETPOLL

  G的獲取除了p.runnext,p.runq和sched.runq外,還有一中G從netpoll中獲取,netpoll是Go針對網路IO的一種優化,本質上為了避免網路IO陷入系統呼叫之中,這樣使得即便G發起網路I/O操作也不會導致M被阻塞(僅阻塞G),從而不會導致大量M被創建出來。

G建立:

  G結構體會複用,對可複用的G管理類似於待執行的G管理,也有Local佇列(p.gfree)和Global佇列(sched.gfree)之分,獲取演算法差不多,優先從p.gfree中獲取(無鎖操作),否則從sched.gfree中獲取並批量轉移一部分(有鎖操作),原始碼參考src/runtime/proc.go:gfget函式。

  從Goroutine的角度來看,通過go func()建立時,會從當前閒置的G佇列取得可複用的G,如果沒有則通過malg新建一個G,然後:

  1. 嘗試將G新增到當前P的runnext中,作為下一個執行的G
  2. 否則放到Local佇列runq中(無鎖)
  3. 如果以上操作都失敗,則新增到Global佇列sched.runq中(有鎖操作,因此也會順便將當P.runq中一半的G轉移到sched.runq)

G的幾種暫停方式:

  1. gosched: 將當前的G暫停,儲存堆疊狀態,以_GRunnable狀態放入Global佇列中,讓當前M繼續執行其它任務。無需對G進行喚醒操作,因為總會有M從Global佇列取得並執行該G。搶佔排程即使用該方式。
  2. gopark: 與goched的最大區別在於gopark沒有將G放回執行佇列,而是位於某個等待佇列中(如channel的waitq,此時G狀態為_Gwaitting),因此G必須被手動喚醒(通過goready),否則會丟失任務。應用層阻塞通常使用這種方式。
  3. notesleep: 既不讓出M,也不讓G和P重新排程,直接讓執行緒休眠直到被喚醒(notewakeup),該方式更快,通常用於gcMark,stopm這類自旋場景
  4. notesleepg: 阻塞G和M,放飛P,P可以和其它M繫結繼續執行,比如可能阻塞的系統呼叫會主動呼叫entersyscallblock,則會觸發 notesleepg
  5. goexit: 立即終止G任務,不管其處於呼叫堆疊的哪個層次,在終止前,確保所有defer正確執行。