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分配到少量線程上去執行,並且利用多核並行,實現強大的併發