Golang排程器的由來
單程式問題
- 單一執行流程,計算機只能一個任務一個任務的處理
- 程式阻塞帶來CPU時間浪費
多程式多執行緒問題
- 程式/執行緒數越多,切換成本越大
- 多執行緒伴隨著同步競爭(鎖,資源衝突)
- 高記憶體佔用:程式虛擬記憶體佔用4GB(32bit OS),線索佔用4MB
- 高CPU排程消耗
執行緒和協程(co-routine)
- 執行緒由CPU排程,是搶佔式的,基本排程也需要陷入核心態;
- 協程由使用者態排程,是協作式的,一個協程讓出CPU後,才執行下一個協程。
協程和執行緒的關係型別
N:1關係:N個協程繫結1個執行緒
優點:協程在使用者態執行緒即完成切換,不會陷入到核心態,這種切換非常的輕量快速。
缺點:無法利用多核加速能力;協程阻塞會造成其他協程都無法執行,沒有併發能力。
1:1關係:1個協程繫結1個執行緒
優點:協程的排程都由CPU完成,不存在N:1缺點,
缺點:協程的建立、刪除和切換的代價都由CPU完成,切換協程代價過大
M:N關係:M個協程繫結1個執行緒
優點:能夠利用多核
缺點:過於依賴協程排程器的優化和演算法
Goroutine
Go中協程被稱為goroutine,它非常輕量,一個goroutine只佔幾KB,
goroutine讓一組可複用的函式執行在一組執行緒之上,即使有協程阻塞,該執行緒的其他協程也可以被runtime
排程,轉移到其他可執行的執行緒上。特點:
- 佔用記憶體更小(幾kb),支援高併發
- 排程更靈活(runtime排程),切換成本低
早期排程器GM
全域性Goroutine(G)佇列,輪詢利用多個thread(M)排程
缺點:
- 建立、銷燬、排程G都需要每個M獲取鎖,這就形成了激烈的鎖競爭。
- M轉移G會造成延遲和額外的系統負載。比如當G中包含建立新協程的時候,M建立了G’,為了繼續執行G,需要把G’交給M’執行,也造成了很差的區域性性,因為G’和G是相關的,最好放在M上執行,而不是其他M'。
- 系統呼叫(CPU在M之間的切換)導致頻繁的執行緒阻塞和取消阻塞操作增加了系統開銷。
GMP模型設計思想
GMP模型
- G goroutine 協程:G中存放併發執行的程式碼入口地址、上下文、執行環境(關聯的P和M)、執行棧等執行相關的資訊。G的新建、休眠、恢復、停止都受到runtime的管理。
- P processor 處理器:程式啟動時建立,是一個管理的資料結構,P主要是降低M對G的複雜性,增加一個間接的控制層資料結構。P控制GO程式碼的並行度,它不是實體。上限值GOMAXPROCS個,預設CPU個數。
- M thread OS核心執行緒:是作業系統層面排程和執行的實體。M僅負責執行,M不停地被喚醒或建立。然後執行。上限值10000。
在Go中,執行緒是執行goroutine的實體,排程器的功能是把可執行的goroutine分配到工作執行緒上。
- 全域性佇列(Global Queue):存放等待執行的G。
- P的本地佇列:同全域性佇列類似,存放的也是等待執行的G,存的數量有限,不超過256個。新建G'時,G'優先加入到P的本地佇列,如果佇列滿了,則會把本地佇列中一半的G移動到全域性佇列。
- P列表:所有的P都在程式啟動時建立,並儲存在陣列中,最多有
GOMAXPROCS
(可配置)個。 - M:執行緒想執行任務就得獲取P,從P的本地佇列獲取G,P佇列為空時,M也會嘗試從全域性佇列拿一批G放到P的本地佇列,或從其他P的本地佇列偷一半放到自己P的本地佇列。M執行G,G執行之後,M會從P獲取下一個G,不斷重複下去。
P的數量
由啟動時環境變數$GOMAXPROCS
或者是由runtime
的方法GOMAXPROCS()
決定。這意味著在程式執行的任意時刻都只有$GOMAXPROCS
個goroutine在同時執行。
M的數量
- go程式啟動時,會設定M的最大數量,預設10000
- runtime/debug中的SetMaxThreads函式,設定M的最大數量
- 一個M阻塞了,會建立新的M。
建立P的時機
程式啟動時,系統根據P的最大數量建立n個P。
建立M的時機
當沒有足夠的M來關聯P並執行其中的可執行的G。
比如所有的M此時都阻塞住了,而P中還有很多就緒任務,就會去尋找空閒的M,而沒有空閒的,就會去建立新的M。
排程器設計策略
複用執行緒
避免頻繁的建立、銷燬執行緒,而是對執行緒的複用。
1)work stealing機制
當本執行緒無可執行的G時,嘗試從其他執行緒繫結的P偷取G,而不是銷燬執行緒。
2)hand off機制
當本執行緒因為G進行系統呼叫(syscall)阻塞時,執行緒釋放繫結的P,把P轉移給其他空閒的執行緒執行。
利用並行
GOMAXPROCS
設定P的數量,最多有GOMAXPROCS
個執行緒分佈在多個CPU上同時執行。GOMAXPROCS
也限制了併發的程度,比如GOMAXPROCS = 核數/2
,則最多利用了一半的CPU核進行並行。
搶佔
- 在co-routine中要等待一個協程主動讓出CPU才執行下一個協程
- 在Go中,一個goroutine最多佔用CPU 10ms,防止其他goroutine被餓死
全域性G佇列
P本地佇列為空時,會嘗試從全域性佇列拿一批G放到P的本地佇列。
go func()排程流程
1、通過 go func()來建立一個goroutine;
2、有兩個儲存G的佇列,一個是區域性排程器P的本地佇列、一個是全域性G佇列。新建立的G會先儲存在P的本地佇列中,如果P的本地佇列已經滿了就會儲存在全域性的佇列中;
3、G只能執行在M中,一個M必須持有一個P,M與P是1:1的關係。M會從P的本地佇列彈出一個可執行狀態的G來執行,如果P的本地佇列為空,就會想其他的MP組合偷取一個可執行的G來執行;
4、一個M排程G執行的過程是一個迴圈機制;
5、當M執行某一個G時候如果發生了syscall或則其餘阻塞操作,M會阻塞,如果當前有一些G在執行,runtime會把這個執行緒M從P中摘除(detach),然後再建立一個新的作業系統的執行緒(如果有空閒的執行緒可用就複用空閒執行緒)來服務於這個P;
6、當M系統呼叫結束時候,這個G會嘗試獲取一個空閒的P執行,並放入到這個P的本地佇列。如果獲取不到P,那麼這個執行緒M變成休眠狀態, 加入到空閒執行緒中,然後這個G會被放入全域性佇列中。
排程器的生命週期
- M0:啟動程式後的編號為0的主執行緒,這個M對應的例項會在全域性變數runtime.m0中,不需要在heap上分配,M0負責執行初始化操作和啟動第一個G, 在之後M0就和其他的M一樣了。
- G0:每次啟動一個M都會第一個建立的gourtine,G0僅用於負責排程的G,G0不指向任何可執行的函式, 每個M都會有一個自己的G0。在排程或系統呼叫時會使用G0的棧空間, 全域性變數的G0是M0的G0。
示例:
package main
import "fmt"
func main() {
fmt.Println("Hello world")
}
- runtime建立最初的m0和g0,並把2者關聯。
- 排程器初始化:初始化m0、棧、GC,建立和初始化P列表。
- 示例程式碼中的main函式是
main.main
,runtime
中也有1個main函式runtime.main
,程式碼經過編譯後,runtime.main
會呼叫main.main
,程式啟動時會為runtime.main
建立main goroutine,並把main goroutine加入到P的本地佇列。 - 啟動m0,m0已經繫結了P,會從P的本地佇列獲取G,獲取到main goroutine。
- G擁有棧,M根據G中的棧資訊和排程資訊設定執行環境
- M執行G
- G退出,再次回到M獲取可執行的G,這樣重複下去,直到
main.main
退出,runtime.main
執行Defer和Panic處理,或呼叫runtime.exit
退出程式。
排程器的生命週期幾乎佔滿了一個Go程式的一生,runtime.main
的goroutine執行之前都是為排程器做準備工作,runtime.main
的goroutine執行,才是排程器的真正開始,直到runtime.main
結束而結束。
視覺化程式設計
1.go tool trace
package main
import (
"os"
"fmt"
"runtime/trace"
)
func main() {
//建立trace檔案
f, err := os.Create("trace.out")
if err != nil {
panic(err)
}
defer f.Close()
//啟動trace goroutine
err = trace.Start(f)
if err != nil {
panic(err)
}
defer trace.Stop()
//main
fmt.Println("Hello World")
}
執行程式
$ go run trace.go
Hello World
會得到一個trace.out
檔案,然後我們可以用一個工具開啟,來分析這個檔案。
$ go tool trace trace.out
2020/02/23 10:44:11 Parsing trace...
2020/02/23 10:44:11 Splitting trace...
2020/02/23 10:44:11 Opening browser. Trace viewer is listening on http://127.0.0.1:33479
瀏覽器訪問:http://127.0.0.1:33479
G資訊
一共有兩個G在程式中,一個是特殊的G0,是每個M必須有的一個初始化的G,這個我們不必討論。
其中G1應該就是main goroutine(執行main函式的協程),在一段時間內處於可執行和執行的狀態。
M資訊
一共有兩個M在程式中,一個是特殊的M0,用於初始化使用,這個我們不必討論。
G1中呼叫了main.main
,建立了trace goroutine g18
。G1執行在P1上,G18執行在P0上。
這裡有兩個P,我們知道,一個P必須繫結一個M才能排程G。
多了一個M2應該就是P0為了執行G18而動態建立的M2.
2.Debug trace
package main
import (
"fmt"
"time"
)
func main() {
for i := 0; i < 5; i++ {
time.Sleep(time.Second)
fmt.Println("Hello World")
}
}
編譯
$ go build trace2.go
通過Debug方式執行
$ GODEBUG=schedtrace=1000 ./trace2
SCHED 0ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=1 idlethreads=1 runqueue=0 [0 0]
Hello World
SCHED 1003ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED 2014ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED 3015ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED 4023ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED
:除錯資訊輸出標誌字串,代表本行是goroutine排程器的輸出;0ms
:即從程式啟動到輸出這行日誌的時間;gomaxprocs
: P的數量,本例有2個P, 因為預設的P的屬性是和cpu核心數量預設一致,當然也可以通過GOMAXPROCS來設定;idleprocs
: 處於idle狀態的P的數量;通過gomaxprocs和idleprocs的差值,我們就可知道執行go程式碼的P的數量;- t
hreads: os threads/M
的數量,包含scheduler使用的m數量,加上runtime自用的類似sysmon這樣的thread的數量; spinningthreads
: 處於自旋狀態的os thread數量;idlethread
: 處於idle狀態的os thread的數量;runqueue=0
: Scheduler全域性佇列中G的數量;[0 0]
: 分別為2個P的local queue中的G的數量。
GMP場景解析
場景1:區域性優先性
P擁有G1,M1獲取P後開始執行G1,G1使用go func()
建立了G2,為了區域性性G2優先加入到P1的本地佇列。
場景2:執行緒複用
G1執行完成後(函式:goexit
),M上執行的goroutine切換為G0,G0負責排程時協程的切換(函式:schedule
)。從P的本地佇列取G2,從G0切換到G2,並開始執行G2(函式:execute
)。實現了執行緒M1的複用。
場景3:本地佇列滿了
假設每個P的本地佇列只能存3個G。G2要建立了6個G,前3個G(G3, G4, G5)已經加入p1的本地佇列,P1本地佇列滿了。
場景4:本地負載均衡
G2在建立G7的時候,發現P1的本地佇列已滿,需要執行負載均衡(把P1中本地佇列中前一半的G,還有新建立G轉移到全域性佇列)。
這些G被轉移到全域性佇列時,會被打亂順序。所以G3,G4,G7被轉移到全域性佇列。
場景5:加入本地佇列
G2建立G8時,P1的本地佇列未滿,所以G8會被加入到P1的本地佇列。
G8加入到P1點本地佇列的原因還是因為P1此時在與M1繫結,而G2此時是M1在執行。所以G2建立的新的G會優先放置到自己的M繫結的P上。
場景6:自旋執行緒
在建立G時,執行的G會嘗試喚醒其他空閒的P和M組合去執行。
假定G2喚醒了M2,M2繫結了P2,並執行G0,但P2本地佇列沒有G,M2此時為自旋執行緒(沒有G但為執行狀態的執行緒,不斷尋找G)。
場景7:全域性佇列負載均衡
M2嘗試從全域性佇列(簡稱“GQ”)取一批G放到P2的本地佇列(函式:findrunnable()
)。M2從全域性佇列取的G數量符合下面的公式:
n = min(len(GQ) / GOMAXPROCS + 1, cap(LQ) / 2 )
至少從全域性佇列取1個g,但每次不要從全域性佇列移動太多的g到p本地佇列,給其他p留點。這是從全域性佇列到P本地佇列的負載均衡。
假定我們場景中一共有4個P(GOMAXPROCS設定為4,那麼我們允許最多就能用4個P來供M使用)。所以M2只從能從全域性佇列取1個G(即G3)移動P2本地佇列,然後完成從G0到G3的切換,執行G3。
場景8:work stealing
假設G2一直在M1上執行,經過2輪後,M2已經把G7、G4從全域性佇列獲取到了P2的本地佇列並完成執行,全域性佇列和P2的本地佇列都空了,如場景8圖的左半部分。
全域性佇列已經沒有G,那m就要執行work stealing(偷取):從其他有G的P哪裡偷取一半G過來,放到自己的P本地佇列。P2從P1的本地佇列尾部取一半的G,本例中一半則只有1個G8,放到P2的本地佇列並執行。
場景9:自旋執行緒最大限制
G1本地佇列G5、G6已經被其他M偷走並執行完成,當前M1和M2分別在執行G2和G8,M3和M4沒有goroutine可以執行,M3和M4處於自旋狀態,它們不斷尋找goroutine。
系統中最多有GOMAXPROCS
個自旋的執行緒(當前例子中的GOMAXPROCS
=4,所以一共4個P),多餘的沒事做執行緒會讓他們休眠。
場景10:阻塞的系統呼叫
假定當前除了M3和M4為自旋執行緒,還有M5和M6為空閒的執行緒(沒有得到P的繫結,注意我們這裡最多就只能夠存在4個P,所以P的數量應該永遠是M>=P, 大部分都是M在搶佔需要執行的P),G8建立了G9,G8進行了阻塞的系統呼叫,M2和P2立即解綁,P2會執行以下判斷:如果P2本地佇列有G、全域性佇列有G或有空閒的M,P2都會立馬喚醒1個M和它繫結,否則P2則會加入到空閒P列表,等待M來獲取可用的p。本場景中,P2本地佇列有G9,可以和其他空閒的執行緒M5繫結。
場景11:非阻塞系統呼叫
G8建立了G9,假如G8進行了非阻塞系統呼叫。
M2和P2會解綁,但M2會記住P2,然後G8和M2進入系統呼叫狀態。當G8和M2退出系統呼叫時,會嘗試獲取P2,如果無法獲取,則獲取空閒的P,如果依然沒有,G8會被記為可執行狀態,並加入到全域性佇列,M2因為沒有P的繫結而變成休眠狀態(長時間休眠等待GC回收銷燬)。
總結
Go排程本質是把大量的goroutine分配到少量執行緒上去執行,並利用多核並行,實現更強大的併發。
Reference
[Golang修養之路](