1. 程式人生 > >Goroutine排程器(一):P、M、G關係

Goroutine排程器(一):P、M、G關係

在瞭解Go的執行時的scheduler之前,需要先了解為什麼需要它,因為我們可能會想,OS核心不是已經有一個執行緒scheduler了嘛?
熟悉POSIX API的人都知道,POSIX的方案在很大程度上是對Unix process進場模型的一個邏輯描述和擴充套件,兩者有很多相似的地方。 Thread有自己的訊號掩碼,CPU affinity等。但是很多特徵對於Go程式來說都是累贅。 尤其是context上下文切換的耗時。另一個原因是Go的垃圾回收需要所有的goroutine停止,使得記憶體在一個一致的狀態。垃圾回收的時間點是不確定的,如果依靠OS自身的scheduler來排程,那麼會有大量的執行緒需要停止工作。

單獨的開發一個Go的排程器,可以是其知道在什麼時候記憶體狀態是一致的,也就是說,當開始垃圾回收時,執行時只需要為當時正在CPU核上執行的那個執行緒等待即可,而不是等待所有的執行緒。

使用者空間執行緒和核心空間執行緒之間的對映關係有:N:1、1:1和M:N
N:1是說,多個(N)使用者執行緒始終在一個核心執行緒上跑,context上下文切換確實很快,但是無法真正的利用多核。
1:1是說,一個使用者執行緒就只在一個核心執行緒上跑,這時可以利用多核,但是上下文switch很慢。
M:N是說, 多個goroutine在多個核心執行緒上跑,這個看似可以集齊上面兩者的優勢,但是無疑增加了排程的難度。

Go的排程器內部有三個重要的結構:M,P,G
M:代表真正的核心OS執行緒,和POSIX裡的thread差不多,真正幹活的人
G:代表一個goroutine,它有自己的棧,instruction pointer和其他資訊(正在等待的channel等等),用於排程。
P:代表排程的上下文,可以把它看做一個區域性的排程器,使go程式碼在一個執行緒上跑,它是實現從N:1到N:M對映的關鍵。

圖中看,有2個物理執行緒M,每一個M都擁有一個context(P),每一個也都有一個正在執行的goroutine。
P的數量可以通過runtime.GOMAXPROCS()來設定,它其實也就代表了真正的併發度,即有多少個goroutine可以同時執行。
圖中灰色的那些goroutine並沒有執行,而是出於ready的就緒態,正在等待被排程。P維護著這個佇列(稱之為runqueue),
Go語言裡,啟動一個goroutine很容易:go function 就行,所以每有一個go語句被執行,runqueue佇列就在其末尾加入一個
goroutine,在下一個排程點,就從runqueue中取出(如何決定取哪個goroutine?)一個goroutine執行。

為何要維護多個上下文P?因為當一個OS執行緒被阻塞時,P可以轉而投奔另一個OS執行緒!
圖中看到,當一個OS執行緒M0陷入阻塞時,P轉而在OS執行緒M1上執行。排程器保證有足夠的執行緒來執行所有的context P。

圖中的M1可能是被建立,或者從執行緒快取中取出。當MO返回時,它必須嘗試取得一個context P來執行goroutine,一般情況下,它會從其他的OS執行緒那裡steal偷一個context過來,
如果沒有偷到的話,它就把goroutine放在一個global runqueue裡,然後自己就去睡大覺了(放入執行緒快取裡)。Contexts們也會週期性的檢查global runqueue,否則global runqueue上的goroutine永遠無法執行。

另一種情況是P所分配的任務G很快就執行完了(分配不均),這就導致了一個上下文P閒著沒事兒幹而系統卻任然忙碌。但是如果global runqueue沒有任務G了,那麼P就不得不從其他的上下文P那裡拿一些G來執行。一般來說,如果上下文P從其他的上下文P那裡要偷一個任務的話,一般就‘偷’runqueue的一半,這就確保了每個OS執行緒都能充分的使用。

 參考連結: