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正確執行。

 

  


免責聲明!

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



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