Goroutine用法及調(diào)度器原理
本文章將分為三個部分:
第一部分介紹進程、線程、協(xié)程的概念,第二部分則介紹Golang中協(xié)程的用法,最后則深入探討Golang協(xié)程的調(diào)度器模型GPM。
1.進程、線程、協(xié)程
在介紹進程、線程、協(xié)程之前,先介紹一下并發(fā)和并行的概念。如下圖所示,并發(fā)不等于并行,并發(fā)是在“某段時間”內(nèi)可以同時運行多個任務,而并行是存在多個任務同時運行。在單核CPU上,并發(fā)的實現(xiàn)是通過時間片來進行多任務切換的,看起來像是同時運行多個任務,這就是并發(fā)。而在多核CPU上,可以讓多個任務同時運行,這就是并行。

進程:進程是系統(tǒng)進行資源分配的基本單位,有獨立的內(nèi)存空間。
線程:線程是 CPU 調(diào)度和分派的基本單位,線程依附于進程存在,每個線程會共享父進程的資源。
協(xié)程:協(xié)程是一種用戶態(tài)的輕量級線程,協(xié)程的調(diào)度完全由用戶控制,協(xié)程間切換只需要保存任務的上下文,沒有內(nèi)核的開銷。
進程中可以包含多個線程,由于各個線程共享了同一片內(nèi)存空間,線程之間的通信是通過共享內(nèi)存來實現(xiàn)的,相比于重量級的進程,線程顯得比較輕量,所以我們可以在一個進程中創(chuàng)建出多個線程。
雖然線程相對進程比較輕量,但是線程仍然會占用較多的資源并且調(diào)度時也會造成比較大的額外開銷,OS線程(操作系統(tǒng)線程)一般都有固定的棧內(nèi)存(通常為2MB),一個goroutine的棧在其生命周期開始時只有很小的棧(典型情況下2KB),goroutine的棧不是固定的,可以按需增大和縮小,goroutine的棧大小限制可以達到1GB。所以在Golang中一次創(chuàng)建十萬左右的goroutine也是可以的。
并且線程進行切換時不止會消耗較多的內(nèi)存空間,對寄存器中的內(nèi)容進行恢復還需要向操作系統(tǒng)申請或者銷毀對應的資源,每一次線程上下文的切換都需要消耗 ~1ms 左右的時間,但是 Go 調(diào)度器對 Goroutine 的上下文切換 ~0.2ms,減少了 80% 的額外開銷。除了減少上下文切換帶來的開銷,Golang 的調(diào)度器還能夠更有效地利用 CPU 的緩存。
2.Golang 協(xié)程的用法
Golang中,使用協(xié)程的方式非常的簡單,只需要在對應函數(shù)前面加上go關鍵字即可,這就創(chuàng)建了一個goroutine。
func hello() {
fmt.Println("Hello Goroutine!")
}
func main() {
go hello() // 這里就創(chuàng)建了一個協(xié)程
fmt.Println("main goroutine done!")
}在Golang中使用 Goroutine 并行執(zhí)行任務并將 Channel 作為 Goroutine 之間的通信方式,雖然使用互斥鎖和共享內(nèi)存在 Golang中也可以完成 Goroutine 間的通信,但是使用 Channel 才是更推薦的做法 — 不要通過共享內(nèi)存的方式進行通信,而是應該通過通信的方式共享內(nèi)存。

3.Goroutine調(diào)度機制
GPM模型
GPM是Golang運行時(runtime)層面的實現(xiàn),是Golang自己實現(xiàn)的一套調(diào)度系統(tǒng)。區(qū)別于操作系統(tǒng)調(diào)度OS線程。G-P-M 模型,包括 4 個重要結構,分別是G、P、M、Sched:

其中:
G:表示 Goroutine,每一個 Goroutine 都包含堆棧、指令指針和其他用于調(diào)度的重要信息,每個 Goroutine 對應一個 G 結構體,G 存儲 Goroutine 的運行堆棧、狀態(tài)以及任務函數(shù),可重用,G 并非執(zhí)行體,每個 G 需要綁定到 P 才能被調(diào)度執(zhí)行。
P:表示調(diào)度的上下文,它可以被看做一個運行于線程 M 上的本地調(diào)度器,P 的數(shù)量決定了系統(tǒng)內(nèi)最大可并行的 G 的數(shù)量
M:表示操作系統(tǒng)的線程,它是被操作系統(tǒng)管理的線程,代表著真正執(zhí)行計算的資源;
Sched:Go 調(diào)度器,它維護有存儲 M 和 G 的隊列以及調(diào)度器的一些狀態(tài)信息等
調(diào)度模型
Go 調(diào)度器中有兩個不同的運行隊列:全局運行隊列(global runqueue: GRQ)和本地運行隊列(local runqueue: LRQ)。
從圖可以看出,每個M對應一個P,每個P也有一個正在運行的G,而其他的G在排隊等待調(diào)度。其中,P 的數(shù)量由用戶設置的 GoMAXPROCS 決定,但是不論 GoMAXPROCS 設置為多大,P 的數(shù)量最大為 256。P的數(shù)量決定了最大并行G的數(shù)量。圖中綠色的G并沒有運行,處于ready狀態(tài)。每個 P 都有一個 LRQ,用于管理分配給在 P 的上下文中執(zhí)行的 Goroutines,這些 Goroutine 輪流被和 P 綁定的 M 進行上下文切換。GRQ 適用于尚未分配給 P 的 Goroutines。
其中,M運行任務就得獲取P,從P的本地隊列獲取G,當P隊列為空時,M也會嘗試從其他P的本地隊列偷一半放到自己P的本地隊列,如果無法獲取,則從全局運行隊列拿一批G放到P的本地隊列。M運行G,G執(zhí)行之后,M會從P獲取下一個G,不斷重復下去。當新建G時,G優(yōu)先加入到P的本地運行隊列,如果本地運行隊列滿了,則會把本地隊列中一半的G移動到全局隊列。

調(diào)度策略
Golang中,為了更加充分利用線程的計算資源,Go 調(diào)度器采取了以下幾種調(diào)度策略:
任務竊?。╳ork-stealing):當本線程無可運行的G時,嘗試從其他線程綁定的P偷取G,而不是銷毀線程。
減少阻塞(hand off):當本線程因為G進行系統(tǒng)調(diào)用阻塞時,線程釋放綁定的P,把P轉移給其他空閑的線程執(zhí)行。
接下來將分別介紹對應的場景。
1. 任務竊取
在實際的運行場景中,有的 Goroutine 運行的快,有的慢,那么勢必肯定會帶來的問題就是,這就導致了這個處理器P很忙,但是其他的P還有任務,此時如果global runqueue沒有任務G了。為了提高 Go 并行處理能力,調(diào)高整體處理效率,當每個 P 之間的 G 任務不均衡時,調(diào)度器允許從 GRQ,或者其他 P 的 LRQ 中獲取 G 執(zhí)行。

如圖所示,當P沒有任務G需要調(diào)度時,會從其他的P竊取G來進行調(diào)度。一般就拿run queue的一半,這就確保了每個OS線程都能充分的使用。
2. 減少阻塞
在實際的場景中,還存在正在執(zhí)行的 Goroutine 阻塞了線程 M 。這種情況下,如果不做出改變,則對應P的LRQ隊列里面的G都會得不到調(diào)度。為了應對這種情況,Go調(diào)度器設置了減少阻塞的策略。

當一個OS線程M0陷入阻塞時,P轉而在運行在M1,M1可能是正被創(chuàng)建,或者從線程緩存中取出。當MO返回時,它必須嘗試取得一個P來運行goroutine,一般情況下,它會從其他的OS線程那里拿一個P過來,
如果沒有拿到的話,它就把goroutine放在一個global runqueue里,然后自己睡眠(放入線程緩存里)。所有的P也會周期性的檢查global runqueue并運行其中的goroutine,否則global runqueue上的goroutine永遠無法執(zhí)行。
Reference
go語言之行–golang核武器goroutine調(diào)度原理、channel詳解
