Golang-排程器原理解析
Golang排程器原理解析
本文主要介紹排程器的由來以及golang排程器為何要如此設計,以及GPM模型解析
一.排程器的由來
1.單程序時代
單程序時代不需要排程器,一切程式都是序列,所以單程序的作業系統會面臨這樣一個問題:
- 程式只能序列執行,一個程序阻塞了,其他程序啥事也做不了,只能等待,會造成CPU時間的嚴重浪費
那麼能不能有多個程序一起來執行多個任務呢?
答案是可以的,後來作業系統就有了最早的併發能力:多程序併發
多程序併發:當一個程序阻塞的時候,切換到另外等待執行的程序,儘量將CPU利用起來。
2.多程序/多執行緒時代
多程序或多執行緒時代就有了排程器的需求,以多程序為例,其會使用CPU排程器來當某個程序阻塞的時候,排程一個合適的程序給CPU。
這種方式解決了阻塞的問題,但也存在一個問題:
- 如果程序數量很多,程序的排程會佔用CPU很多的時間(程序建立,銷燬,切換等),CPU利用率不高
對比執行緒,雖然其排程成本會比程序小很多,但實際上多執行緒程式的開發和設計也比較複雜,而且在當前網際網路業務環境下,為每個任務都建立一個執行緒是不現實的,這會大量的消耗記憶體(程序佔用4G(32位),而執行緒大約也要4M)
所以,多執行緒/多程序時代,會面臨這樣兩個問題
- 高記憶體佔用
- 排程的高CPU消耗
但是,其實一個執行緒可以分為核心態執行緒和使用者態執行緒,一個使用者態執行緒必須要繫結一個核心態執行緒,但是CPU並不知道使用者態執行緒的存在,它只知道它執行的是一個核心態執行緒(Linux的PCB程序控制塊)
3.協程時代
我們可以將核心執行緒依然叫做執行緒,把使用者態執行緒叫做協程
那麼協程和執行緒就有三種對映關係:
- N : 1 : N個協程,一個執行緒
- 1 : 1 : 一個協程,一個執行緒
- N : M : N個協程,M個執行緒
下面我們分別討論一下這三種對映關係的優點和缺點:
N : 1 關係
N個協程繫結一個執行緒
優點:
- 協程在使用者態即完成切換,不會陷入到核心態,這種切換非常輕量快速
缺點:
- 無法使用硬體的多核加速
- 一旦協程阻塞,造成執行緒阻塞,本程序的其他協程就都無法執行了,根本沒有併發能力!
1 : 1 關係
一個協程繫結一個執行緒
優點:
- 容易實現,不存在N比1的缺點
缺點:
- 協程的建立,刪除,切換的代價都由CPU完成,代價有點昂貴
M : N 關係
M個協程,N個執行緒
克服了以上兩種模型的缺點,但是實現較為複雜
協程和執行緒的排程是有區別的,執行緒是由CPU排程的,是搶佔式的,協程是由使用者態排程,是協作式的,一個協程讓出CPU後,才執行下一個協程
二. goroutine 和 go排程器
1. goroutine
go提供了goroutine。
goroutine來自於協程的概念,讓一組可複用的函式執行在一組執行緒之上,即使有協程被阻塞,該執行緒的其他協程也可以被runtime排程,轉移到其他可執行的執行緒上,最關鍵的是,程式設計師看不到這些底層的細節,這就降低了程式設計的難度,提供了更容易的併發
goroutine非常的輕量,一個goroutine只佔幾KB,並且只幾KB就足夠goroutine執行完,這樣就能在有限的記憶體空間內,支援大量的goroutine,支援更多的併發,雖然一個goroutine的棧只有幾KB,但實際是可伸縮的,如果goroutine需要更多的空間,runtime會為goroutine自動分配。
goroutine的特點:
- 佔用記憶體很小(幾KB)
- 排程更加靈活(由runtime排程)
2. go排程器的演變歷史
go目前使用的排程器是2012年重新設計的,因為之前的排程器存在效能問題,我們先來研究一下廢棄的排程器,這樣才能更好的瞭解現有的排程器為何如此設計
先行約定一下,我們採用G來表示goroutine,M來表示執行緒
廢棄的排程器僅有一個全域性的go協程佇列,所以多個M如果要訪問此全域性的G佇列,都需要加鎖,鎖的粒度會非常的大,極度的影響排程器的效能,所以我們可以總結一下,老排程器的幾個缺點:
- 鎖競爭激烈: 每個M要需要加鎖訪問全域性的G佇列
- 延遲和額外的系統負載:比如G中建立新的協程的時候,最好是新建的協程能給當前M,而不是其他M,區域性性很差
- 系統呼叫(CPU在M之間切換),導致頻繁的系統阻塞和取消阻塞的操作,都增加了系統的開銷
正是基於以上缺點的改進,GPM模型的go排程器,誕生了!
三.GPM模型的Go排程器及其設計思想
在GPM模型的go排程器中,除了M和G,又引進了P
- G : 協程
- P : 邏輯處理器,包含了執行goroutine的資源和可執行的G佇列
- M : 核心執行緒,負責執行G
- 全域性佇列:存放等待執行的G
- P的本地佇列:和全域性佇列類似,存放的也是等待執行的G,但是存放的數量有限,不會超過256個,在G中新建G時,新建的G優先加入P的本地佇列,如果佇列滿了,則把本地佇列中一半的G移動到全域性佇列
- P列表:所有的P都在程式啟動時建立,並保證的陣列中,最多有GOMAXPROCS個
- M:執行緒想執行G就得獲得P,從P的本地佇列獲取G,P佇列為空時,M會嘗試從全域性佇列拿一批G放到P的本地佇列,或從其他P的本地佇列偷取一般放入自己P的本地佇列
goroutine排程器和OS排程器是通過M結合起來的,每個M都代表了一個核心執行緒,OS排程器負責把核心執行緒分配到CPU的核上執行
一.go排程器的設計思想:
1.複用執行緒
避免頻繁的建立,銷燬執行緒,而是對執行緒進行復用
- work stealing 機制 :當M繫結的P佇列中可執行的G時,嘗試從其他M繫結的P佇列中偷取G,而不是銷燬M
- hand off機制 : 當M進行系統呼叫而阻塞時,執行緒釋放繫結的P
2.利用並行
GOMAXPROCS設定P的數量,最多有GOMAXPROCS個執行緒分佈在多個CPU上同時執行,GOMAXPROCS同時也限制了併發的程度,比如GOMAXPROCS=核數/2,則最多利用了一半的CPU核進行並行
3.協作排程
在coroutine中要等待一個協程主動讓出CPU才執行下一個協程,但是在go中,一個goroutine最多佔用CPU 10ms,防止其他的goroutine餓死,這就是goroutine不同於coroutine的一個地方
4.全域性G佇列
在新的排程器中仍然有全域性G佇列,但功能已經被弱化了,當M執行work stealing從其他P偷不到G時,它可以從全域性G佇列獲取G
二.啟動一個goroutine的排程流程
通過上圖,我們可以得到幾個結論:
- 通過go關鍵字來啟動一個goroutine
- 有兩類G的儲存佇列,一個是P的區域性G佇列,一個是全域性G佇列,新建G會儲存在P的本地G佇列中,如果P的本地G佇列滿了就會儲存在全域性的G佇列中
- G只能執行在M中,一個M必須持有一個P,M與P的關係是一比一,M會從P的本地G佇列中彈出一個G來執行,如果P的本地佇列為空,就會想衝其他的MP組合中偷取G來執行
- 一個M排程G執行的過程是一個迴圈機制
- 當M執行每一個G的時候如果發生了系統呼叫或阻塞操作,那麼這個M會被阻塞,如果當前有一些G在這個MP組合,runtime會吧這個M從P中摘除,然後再建立一個新的M或者尋找一個空閒的M來服務P
- 當M的系統呼叫或阻塞操作結束的時候,這個G會嘗試獲取一個空閒的P,並放入到這個P的本地佇列,如果獲取不到P,則此M變成休眠狀態,加入到空閒M中,然後這個G會被放到全域性的G佇列中
三.特殊的M0和G0
- M0:M0是啟動程式後編號為0的執行緒,M0負責執行初始化操作和啟動第一個G,M0對應的例項會在全域性遍歷runtime.m0中
- G0:每個M都有自己的G0,G0僅負責排程G,不執行其他任何可執行的函式,每啟動一個M,都會建立屬於此M的G0
四.有關P和M數量的問題
- P的數量
- 由啟動時環境變數\(GOMAXPROCS***或者***runtime***的***GOMAXPROCS***()決定,這意味著在程式執行的任意時刻都只有***\)GOMAXPROCS個goroutine在同時執行
- M的數量
- go語言本身的限制:go程式啟動時,會設定M的最大數量,預設10000,但是核心很難支援這麼多執行緒數,所以這個限制可以忽略
- runtime/debug中serMaxThreads函式,設定M的最大數量
- 一個M阻塞了,會建立新的M
M和P的數量沒有絕對關係,一個M阻塞,P就會去建立或者切換到另外一個M,所以,即使P的預設數量是1,也有可能會建立很多個M出來
五.P和M何時會被建立
- P何時建立
- 在確定P的最大數量N之後,runtime會根據這個建立N個P
- M何時建立
- 沒有足夠的M來關聯P,並執行其中可執行的G時,比如所有的M都阻塞住了,而P中還有很多待執行的G,就會去尋找空閒的M,沒有空閒的M就會去建立新的M
四.總結
Go排程本質是把大量的goroutine分配到少量執行緒上去執行,並且利用多核並行,實現強大的併發
心之所向,素履以往