Golang 調度器 GPM模型
1 多進程/線程時代有了調度器需求
在多進程/多線程的操作系統中,就解決了阻塞的問題,因為一個進程阻塞cpu可以立刻切換到其他進程中去執行,而且調度cpu的算法可以保證在運行的進程都可以被分配到cpu的運行時間片。這樣從宏觀來看,似乎多個進程是在同時被運行。
上圖為一個CPU通過調度器切換CPU時間軸的情景。如果未來滿足宏觀上每個進程/線程是一起執行的,則CPU必須切換,每個進程會被分配到一個時間片中。
但新的問題就又出現了,進程擁有太多的資源,進程的創建、切換、銷毀,都會占用很長的時間,CPU雖然利用起來了,但如果進程過多,CPU有很大的一部分都被用來進行進程調度了。如下圖所示:
對于Linux操作系統來言,CPU對進程和線程的態度是一樣的,如圖1.3所示,如果系統的CPU數量過少,而進程/線程數量比較龐大,則相互切換的頻率也就會很高,其中中間的切換成本越來越大。這一部分的性能消耗實際上是沒有做在對程序有用的計算算力上,所以盡管線程看起來很美好,但實際上多線程開發設計會變得更加復雜,開發者要考慮很多同步競爭的問題,如鎖、資源競爭、同步沖突等。
2 協程來提高CPU利用率
那么如何才能提高CPU的利用率呢?多進程、多線程已經提高了系統的并發能力,但是在當今互聯網高并發場景下,為每個任務都創建一個線程是不現實的,因為這樣就會出現極大量的線程同時運行,不僅切換頻率高,也會消耗大量的內存:進程虛擬內存會占用4GB(32位操作系統),而線程也要大約4MB。大量的進程或線程出現了以下兩個新的問題。
- (1)高內存占用。
- (2)調度的高消耗CPU。
工程師發現其實可以把一個線程分為“內核態”和“用戶態”兩種形態的線程。所謂用戶態線程就是把內核態的線程在用戶態實現了一遍而已,目的是更輕量化(更少的內存占用、更少的隔離、更快的調度)和更高的可控性(可以自己控制調度器)。用戶態中的所有東西內核態都看得見,只是對于內核而言用戶態線程只是一堆內存數據而已。
一個用戶態線程必須綁定一個內核態線程,但是CPU并不知道有用戶態線程的存在,它只知道它運行的是一個內核態線程(Linux的PCB進程控制塊),如下圖所示:
如果將線程再進行細化,內核線程依然叫 線程(Thread)
,而用戶線程則叫 協程(Co-routine)
。操作系統層面的線程就是所謂的內核態線程,用戶態線程則多種多樣,只要能滿足在同一個內核線程上執行多個任務,例如Co-routine、Go的Goroutine、C#的Task等。
既然一個協程可以綁定一個線程,那么能不能多個協程綁定一個或者多個線程呢?接下來有3種協程和線程的映射關系,它們分別是 N : 1
關系、 1 : 1
關系和 M : N
關系。
3 N比1關系
N個協程綁定1個線程,優點就是協程在用戶態線程即完成切換,不會陷入內核態,這種切換非常輕量快速,但缺點也很明顯,1個進程的所有協程都綁定在1個線程上,如圖所示。
N:1關系面臨的幾個問題如下:
- (1) 某個程序用不了硬件的多核加速能力。
- (2) 某一個協程阻塞,會造成線程阻塞,本進程的其他協程都無法執行了,進而導致沒有任何并發能力。
4 1比1關系
1個協程綁定1個線程,這種方式最容易實現。協程的調度都由CPU完成了,雖然不存在N:1的缺點,但是協程的創建、刪除和切換的代價都由CPU完成,成本和代價略顯昂貴。協程和線程的1:1關系如圖所示。
5 M比N關系
M個協程綁定1個線程,是 N: 1
和 1 : 1
類型的結合,克服了以上兩種模型的缺點,但實現起來最為復雜。同一個調度器上掛載M個協程,調度器下游則是多個CPU核心資源。協程跟線程是有區別的,線程由CPU調度是搶占式的,協程由用戶態調度是協作式的,一個協程讓出CPU后,才執行下一個協程,所以針對 M : N
模型的中間層的調度器設計就變得尤為重要,提高線程和協程的綁定關系和執行效率也變為不同語言在設計調度器時的優先目標。
6 Go語言的協程goroutine
Go語言為了提供更容易使用的并發方法,使用了Goroutine和Channel。Goroutine來自協程的概念,讓一組可復用的函數運行在一組線程之上,即使有協程阻塞,該線程的其他協程也可以被runtime調度,從而轉移到其他可運行的線程上。最關鍵的是,程序員看不到這些底層的細節,這就降低了編程的難度,提供了更容易的并發。
在Go語言中,協程被稱為Goroutine,它非常輕量,一個Goroutine只占幾KB,并且這幾KB就足夠Goroutine運行完,這就能在有限的內存空間內支持大量Goroutine,從而支持更多的并發。雖然一個Goroutine的棧只占幾KB,但實際是可伸縮的,如果需要更多內存,則runtime會自動為Goroutine分配。
Goroutine的特點,占用內存更小(幾KB)和調度更靈活(runtime調度)。
7 被廢棄的goroutine調度器
Go語言目前使用的調度器是2012年重新設計的,因為之前的調度器性能存在問題,所以使用4年就被廢棄了,那么先來分析一下被廢棄的調度器是如何運作的。
通常用符號G表示Goroutine,用M表示線程。接下來有關調度器的內容均采用圖1.8所示的符號來統一表達。
早期的調度器是基于M:N的基礎上實現的,圖1.9是一個概要圖形,所有的協程,也就是G都會被放在一個全局的Go協程隊列中,在全局隊列的外面由于是多個M的共享資源,所以會加上一個用于同步及互斥作用的鎖。
M想要執行、放回G都必須訪問全局G隊列,并且M有多個,即多線程訪問同一資源需要加鎖進行保證互斥/同步,所以全局G隊列是由互斥鎖進行保護的。
不難分析出來,老調度器有以下幾個缺點:
- (1) 創建、銷毀、調度G都需要每個M獲取鎖,這就形成了激烈的鎖競爭。
- (2) M轉移G會造成延遲和額外的系統負載。例如當G中包含創建新協程的時候,M創建了G′,為了繼續執行G,需要把G′交給M2(假如被分配到)執行,也造成了很差的局部性,因為G′和G是相關的,最好放在M上執行,而不是其他M2,如圖1.10所示
- (3) 系統調用(CPU在M之間的切換)導致頻繁的線程阻塞和取消阻塞操作增加了系統開銷。
8 Goroutine調度器的GMP模型的設計思想
面對之前調度器的問題,Go設計了新的調度器。在新調度器中,除了 M(線程)
和 G(協程)
,又引進了 P(處理器)
。
處理器包含了運行Goroutine的資源,如果線程想運行Goroutine,必須先獲取P,P中還包含了可運行的G隊列。
9 GPM模型
在Go中,線程是運行Goroutine的實體,調度器的功能是把可運行的Goroutine分配到工作線程上。
在GPM模型中有以下幾個重要的概念,如圖1.12所示。
- (1)全局隊列(Global Queue): 存放等待運行的G。全局隊列可能被任意的P去獲取里面的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的核上執行。
10 有關P和M個數的問題
- (1) P的數量由啟動時環境變量
$GOMAXPROCS
或者由runtime
的方法GOMAXPROCS( )
決定。這意味著在程序執行的任意時刻都只有$GOMAXPROCS
個 Goroutine在 同時運行。 - (2)M的數量由Go語言本身的限制決定,Go程序啟動時會設置M的最大數量,默認為10000個,但是內核很難支持這么多的線程數,所以這個限制可以忽略。
runtime/deBug
中的SetMaxThreads( )
函數可設置M的最大數量,當一個M阻塞了時會創建新的M。
M與P的數量沒有絕對關系,一個M阻塞,P就會去創建或者切換另一個M,所以,即使P的默認數量是1,也有可能會創建很多個M出來。
11 有關P和M何時被創建
- (1) P創建的時機在確定了P的最大數量n后,運行時系統會根據這個數量創建n個P。
- (2) M創建的時機是在當沒有足夠的M來關聯P并運行其中可運行的G的時候。例如所有的M此時都阻塞住了,而P中還有很多就緒任務,就會去尋找空閑的M,如果此時沒有空閑的M,就會去創建新的M。
12 調度器的設計策略
策略一:復用線程
避免頻繁地創建、銷毀線程,而是對線程的復用。
1)偷取(Work Stealing)機制
當本線程無可運行的G時,嘗試從其他線程綁定的P偷取G,而不是銷毀線程,如圖1.13所示
這里需要注意的是,偷取的動作一定是由P發起的,而非M,因為P的數量是固定的,如果一個M得不到一個P,那么這個M是沒有執行的本地隊列的,更談不上向其他的P隊列偷取了。
2)移交(Hand Off)機制
當本線程因為G進行系統調用阻塞時,線程會釋放綁定的P,把P轉移給其他空閑的線程執行,如圖1.14所示,此時若在M1的GPM組合中,G1正在被調度,并且已經發生了阻塞,則這個時候就會觸發移交的設計機制。GPM模型為了更大程度地利用M和P的性能,不會讓一個P永遠被一個阻塞的G1耽誤之后的工作,所以遇見這種情況的時候,移交機制的設計理念是應該立刻將此時的P釋放出來
如圖1.15所示,為了釋放P,所以將P和M1、G1分離,M1由于正在執行當前的G1,全部的程序棧空間均在M1中保存,所以M1此時應該與G1一同進入阻塞的狀態,但是已經被釋放的P需要跟另一個M進行綁定,所以就會選擇一個M3(如果此時沒有M3,則會創建一個新的或者喚醒一個正在睡眠的M)進行綁定,這樣新的P就會繼續工作,接收新的G或者從其他的隊列中實施偷取機制。
策略二:利用并行
GOMAXPROCS
設置P的數量,最多有 GOMAXPROCS
個線程分布在多個CPU上同時運行。 GOMAXPROCS
也限制了并發的程度,例如 GOMAXPROCS=核數/2
,表示最多利用一半的CPU核進行并行。
策略三:搶占
在Co-routine中要等待一個協程主動讓出CPU才執行下一個協程,在Go中,一個Goroutine最多占用CPU 10ms,防止其他Goroutine無資源可用,這就是Goroutine不同于Co-routine的一個地方。
Co-routine(C語言中的協程),用戶態線程。
coroutine 是基于 ucontext 的一個 C 語言協程庫實現
策略四:全局G隊列
在新的調度器中依然有全局G隊列,但功能已經被弱化了,當M執行偷取,但從其他P偷不到G時,它可以從全局G隊列獲取G。
13 go func() 調度流程
如果執行一行代碼 go func( )
,則在GPM模型上的概念里會執行哪些操作。
(1)通過 go func( )
創建一個Goroutine,
(2)有兩個存儲G的隊列,一個是局部調度器P的本地隊列,另一個是全局G隊列。新創建的G會先保存在P的本地隊列中,如果P的本地隊列已經滿了,就會保存在全局的隊列中,如圖1.19所示。
(3)G只能運行在M中,一個M必須持有一個P,M與P是1:1的關系。M會從P的本地隊列彈出一個可執行狀態的G來執行,如果P的本地隊列為空,則會從全局隊列進行獲取,如果從全局隊列獲取不到,則會向其他的MP組合偷取一個可執行的G來執行,如圖1.20所示。
(4)一個M調度G執行的過程是一個循環機制,如圖1.21所示。
(5)當M執行某一個G時如果發生了syscall或者其余阻塞操作,則M會阻塞,如果當前有一些G在執行,runtime則會把這個線程M從P中移除(Detach),然后創建一個新的操作系統線程(如果有空閑的線程可用就復用空閑線程)來服務于這個P。
(6)當M系統調用結束時,這個G會嘗試獲取一個空閑的P執行,并放入這個P的本地隊列。如果獲取不到P,則這個線程M會變成休眠狀態,加入空閑線程中,然后這個G會被放入全局隊列中。
14 調度器的生命周期
在Go語言調度器的GPM模型中還有兩個比較特殊的角色,它們分別是M0和G0。
M0
(1)啟動程序后的編號為0的主線程。
(2)在全局命令runtime.m0中,不需要在heap堆上分配。
(3)負責執行初始化操作和啟動第1個G。
(4)啟動第1個G后,M0就和其他的M一樣了。
G0
(1)每次啟動一個M,創建的第1個Goroutine就是G0。
(2)G0僅用于負責調度G。
(3)G0不指向任何可執行的函數。
(4)每個M都會有一個自己的G0。
(5)在調度或系統調度時,會使用M切換到G0,再通過G0調度。
(6)M0的G0會放在全局空間。
一個Goroutine的創建周期如果加上M0和G0的角色,則整體的流程如圖1.24所示。
下面跟蹤一段代碼,對調度器里面的結構做一個分析,代碼如下:
package mainimport "fmt"func main() {fmt.Println("Hello world")
}
整體的分析過程如下:
(1)runtime創建最初的線程 M0
和 Goroutine G0
,并把二者關聯。
(2)調度器初始化:初始化M0、棧、垃圾回收,以及創建和初始化由 GOMAXPROCS
個 P
構成的 P列表
,如圖1.25所示。
(3)示例代碼中的 main( )
函數是 main.main
,runtime中也有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
結束而結束。
參考
- https://blog.csdn.net/flynetcn/article/details/126628952
- https://blog.csdn.net/weixin_43495948/article/details/129415438
- 《深入理解Go語言》劉丹冰