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:
- P的個數由GOMAXPROCS指定,是固定的,因此限制最大併發數
- M的個數是不定的,由Go Runtime調整,預設最大限制為10000個
基本排程過程:
- 建立一個 G 物件;
- 將 G 儲存至 P中;
- P 去喚醒(告訴)一個 M,然後繼續執行它的執行序(分配下一個 G);
- M 尋找空閒的 P,讀取該 P 要分配的 G;
- 接下來 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(釋放自由列表中多餘的項減少記憶體佔用)等處理。
搶佔條件:
- 如果 P 在系統呼叫中,且時長已經過一次 sysmon 後,則搶佔;
呼叫handoffp
解除 M 和 P 的關聯。
- 如果 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喚醒一次,主要執行:
- 釋放閒置超過5分鐘的span實體記憶體;
- 如果超過2分鐘沒有垃圾回收,強制執行;
- 將長時間未處理的netpoll結果新增到任務佇列;
- 向長時間執行的G任務發出搶佔排程;
- 收回因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,然後:
- 嘗試將G新增到當前P的runnext中,作為下一個執行的G
- 否則放到Local佇列runq中(無鎖)
- 如果以上操作都失敗,則新增到Global佇列sched.runq中(有鎖操作,因此也會順便將當P.runq中一半的G轉移到sched.runq)
G的幾種暫停方式:
- gosched: 將當前的G暫停,儲存堆疊狀態,以
_GRunnable
狀態放入Global佇列中,讓當前M繼續執行其它任務。無需對G進行喚醒操作,因為總會有M從Global佇列取得並執行該G。搶佔排程即使用該方式。 - gopark: 與goched的最大區別在於gopark沒有將G放回執行佇列,而是位於某個等待佇列中(如channel的waitq,此時G狀態為
_Gwaitting
),因此G必須被手動喚醒(通過goready),否則會丟失任務。應用層阻塞通常使用這種方式。 - notesleep: 既不讓出M,也不讓G和P重新排程,直接讓執行緒休眠直到被喚醒(notewakeup),該方式更快,通常用於gcMark,stopm這類自旋場景
- notesleepg: 阻塞G和M,放飛P,P可以和其它M繫結繼續執行,比如可能阻塞的系統呼叫會主動呼叫entersyscallblock,則會觸發 notesleepg
- goexit: 立即終止G任務,不管其處於呼叫堆疊的哪個層次,在終止前,確保所有defer正確執行。