協程是更輕量的使用者態執行緒,是Go語言的核心。那麼如何去排程這些協程何時去執行、如何更合理的分配作業系統資源,需要一個設計良好的排程器來支援。
什麼才是一個好的排程器?能在適當的時機將合適的協程分配到合適的位置,保證公平和效率。
從go func說起
func main() {
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
}()
}
time.Sleep(1 * time.Second)
}
這段程式碼中,我們開啟了10個協程,每個協程列印去列印i這個變數。由於這10個協程的排程時機並不固定,所以等到協程被排程執行的時候才會去取迴圈中變數i的值。
我們寫的這段程式碼,每個我們開啟的協程都是一個計算任務,這些任務會被提交給go的runtime。如果計算任務非常多,有成千上萬個,那麼這些任務是不可能同時被立刻執行的,所以這個計算任務一定會被先暫存起來,一般的做法是放到記憶體的佇列中等待被執行。
而消費端則是一個go runtime維護的一個排程迴圈。排程迴圈簡單來說,就是不斷從佇列中消費計算任務並執行。這裡本質上就是一個生產者-消費者模型,實現了使用者任務與排程器的解耦。
這裡圖中的G就代表我們的一個goroutine計算任務,M就代表作業系統執行緒
排程策略
接下來我們詳細講解一下排程策略。
生產端
生產端1.0
接上面的例子,我們生產了10個計算任務,我們一定是要在記憶體中先把它存起來等待排程器去消費的。那麼很顯然,最合適的資料結構就是佇列,先來先服務。但是這樣做是有問題的。現在我們都是多核多執行緒模型,消費者肯定不止有一個,所以如果多個消費者去消費同一個佇列,會出現執行緒安全的問題,必須加鎖。所有計算任務G都必須在M上來執行。
生產端2.0
在Go中,為了解決加鎖的問題,將全域性佇列拆成了多個本地佇列,而這個本地佇列由一個叫做P的結構來管理。
這樣一來,每個M只需要去先找到一個P結構,和P結構繫結,然後執行P本地佇列裡的G即可,完美的解決了加鎖的問題。
但是每個P的本地佇列長度不可能無限長(目前為256),想象一下有成千上萬個go routine的場景,這樣很可能導致本地佇列無法容納這麼多的Goroutine,所以Go保留了全域性佇列,用以處理上述情況。
那麼為什麼本地佇列是陣列,而全域性佇列是連結串列呢?由於全域性佇列是本地佇列的兜底策略,所以全域性佇列大小必須是無限的,所以必須是一個連結串列。
全域性佇列被分配在全域性的排程器結構上,只有一份:
type schedt struct {
...
// Global runnable queue.
runq gQueue // 全域性佇列
runqsize int32 // 全域性佇列大小
...
}
那麼本地佇列為什麼做成陣列而不是連結串列呢?因為作業系統記憶體管理會將連續的儲存空間提前讀入快取(區域性性原理),所以陣列往往會被都讀入到快取中,對快取友好,效率較高;而連結串列由於在記憶體中分佈是分散的,往往不會都讀入到快取中,效率較低。所以本地佇列綜合考慮效能與擴充套件性,還是選擇了陣列作為最終實現。
而Go又為了實現區域性性原理,在P中又加了一個runnext的結構,這個結構大小為1,在runnext中的G永遠會被最先排程執行。接下來會講為什麼需要這個runnext結構。完整的生產端資料結構如下:
P結構的定義:
type p struct {
...
// Queue of runnable goroutines. Accessed without lock.
runqhead uint32 // 本地佇列隊頭
runqtail uint32 // 本地佇列隊尾
runq [256]guintptr // 本地佇列,大小256
runnext guintptr // runnext,大小為1
...
}
完整的生產流程
- 我們執行go func的時候,主執行緒m0會呼叫newproc()生成一個G結構體,這裡會先選定當前m0上的P結構
- 每個協程G都會被嘗試先放到P中的runnext,若runnext為空則放到runnext中,生產結束
- 若runnext滿,則將原來runnext中的G踢到本地佇列中,將當前G放到runnext中。生產結束
- 若本地佇列也滿了,則將本地佇列中的G拿出一半,加上當前協程G,這個拼成的結構在原始碼中叫batch,會將batch一起放到全域性佇列中,生產結束。這樣一來本地佇列的空間就不會滿了,接下來的生產流程不會被本地佇列滿而阻塞
所以我們看到,最終runnext中的G一定是最後生產出來的G,也會被優先被排程去執行。這裡是考慮到區域性性原理,最近建立出來的協程一定會被最先執行,優先順序是最高的。
runqput的邏輯:
func runqput(_p_ *p, gp *g, next bool) {
// 先嚐試放到runnext中
if randomizeScheduler && next && fastrand()%2 == 0 {
next = false
}
if next {
retryNext:
// 拿到老的runnext值。
oldnext := _p_.runnext
// 交換當前runnext的老的G和當前G的地址,相當於將當前G放入了runnext
if !_p_.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {
goto retryNext
}
// 老的runnext為空,生產結束
if oldnext == 0 {
return
}
// 老的runnext不空,則將被替換掉的runnext賦值給gp,然後下面會set到本地佇列的尾部
gp = oldnext.ptr()
}
retry:
// 嘗試放到本地佇列
h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with consumers
t := _p_.runqtail
// 本地佇列沒有滿,那麼set進去
if t-h < uint32(len(_p_.runq)) {
_p_.runq[t%uint32(len(_p_.runq))].set(gp)
atomic.StoreRel(&_p_.runqtail, t+1) // store-release, makes the item available for consumption
return
}
// 如果本地佇列不滿剛才會直接return;若已滿會走到這裡,會將本地佇列的一半G放到全域性佇列中
if runqputslow(_p_, gp, h, t) {
return
}
// the queue is not full, now the put above must succeed
goto retry
}
消費端
消費端就是一個排程迴圈,不斷的從本地佇列和全域性佇列消費G、給G繫結一個M、執行G,然後再次消費G、給G繫結一個M、執行G...那麼執行這個排程迴圈的人是誰呢?答案是g0,每個M上,都有一個g0,控制自己執行緒上面的排程迴圈:
type m struct {
g0 *g // goroutine with scheduling stack
...
}
g0是一個特殊的協程。為了給接下來M執行計算任務G做準備,g0需要先幫忙獲取一個執行緒M,根據隨機演算法給M繫結一個P,讓P上的計算任務G得到執行,然後正式進入排程迴圈。整體的排程迴圈分為四個步驟:
- schedule:g0來執行,處理具體的排程策略,如從P的runnext/本地或者全域性佇列中獲取一個G,然後會呼叫execute()
- execute:把G和M繫結,初始化一些欄位,呼叫gogo()
- gogo:和作業系統架構相關,會將待執行的G排程到執行緒M上來執行,完成棧的切換
- goexit:執行一些清理邏輯,並呼叫schedule()重新開始一輪排程迴圈
即每次排程迴圈,都會完成g0 -> G -> g0的上下文切換。
schedule
schedule是排程迴圈的核心。由於P中的G分佈在runnext、本地佇列和全域性佇列中,則需要挨個判斷是否有可執行的G,大體邏輯如下:
- 先到P上的runnext看一下是否有G,若有則直接返回
- runnext為空,則去本地佇列中查詢,找到了則直接返回
- 本地佇列為空,則去阻塞的去全域性佇列、網路輪詢器、以及其他P中查詢,一直阻塞直到獲取到一個可用的G為止
原始碼實現如下:
func schedule() {
_g_ := getg()
var gp *g
var inheritTime bool
...
if gp == nil {
// 每執行61次排程迴圈會看一下全域性佇列。為了保證公平,避免全域性佇列一直無法得到執行的情況,當全域性執行佇列中有待執行的G時,通過schedtick保證有一定機率會從全域性的執行佇列中查詢對應的Goroutine;
if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp = globrunqget(_g_.m.p.ptr(), 1)
unlock(&sched.lock)
}
}
if gp == nil {
// 先嚐試從P的runnext和本地佇列查詢G
gp, inheritTime = runqget(_g_.m.p.ptr())
}
if gp == nil {
// 仍找不到,去全域性佇列中查詢。還找不到,要去網路輪詢器中查詢是否有G等待執行;仍找不到,則嘗試從其他P中竊取G來執行。
gp, inheritTime = findrunnable() // blocks until work is available
// 這個函式是阻塞的,執行到這裡一定會獲取到一個可執行的G
}
...
// 呼叫execute,繼續排程迴圈
execute(gp, inheritTime)
}
其中schedtick這裡,每執行61次的排程迴圈,就需要去全域性佇列嘗試獲取一次。為什麼要這樣做呢?假設有十萬個G源源不斷的加入到P的本地佇列中,那麼全域性佇列中的G可能永遠得不到執行被餓死,所以必須要在從本地佇列獲取之前有一個判斷邏輯,定期從全域性佇列獲取G以保證公平。
與此同時,排程器會將全域性佇列中的一半G都拿過來,放到當前P的本地佇列中。這樣做的目的是,如果下次排程迴圈到來的時候,就不必去加鎖到全域性佇列中在獲取一次G了,效能得到了很好的保障。
這裡去其他P中查詢可用G的邏輯也叫work stealing,即工作竊取。這裡也是會使用隨機演算法,隨機選擇一個P,偷取該P中一半的G放入當前P的本地佇列,然後取本地佇列尾部的一個G拿來執行。
GMP模型
到這裡相信大家已經瞭解了GMP的概念,我們最終來總結一下:
- G:goroutine,代表一個計算任務,由程式碼和上下文(如當前程式碼執行的位置、棧資訊、狀態等)組成
- M:machine,系統執行緒,想要在CPU上執行程式碼必須有執行緒,通過系統呼叫clone建立
- P:processor,虛擬處理器。M必須獲得P才能執行P佇列中的G程式碼,否則會陷入休眠
阻塞處理
以上只是假設G正常執行的情況。如果G存在阻塞等待(如channel、系統呼叫)等,那麼需要將此時此刻的M與P上的G進行解綁,讓M執行其他P上的G,從而最大化提升CPU利用率。以及從系統呼叫中陷入、恢復需要觸發排程器排程的時機,這部分邏輯會在下一篇文章中做出講解。
關注我們
歡迎對本系列文章感興趣的讀者訂閱我們的公眾號,關注博主下次不迷路~