Go調度器介紹和容易忽視的問題
- 2019 年 10 月 3 日
- 筆記
本文記錄了本人對Golang調度器的理解和跟蹤調度器的方法,特別是一個容易忽略的goroutine執行順序問題,看了很多篇Golang調度器的文章都沒提到這個點,分享出來一起學習,歡迎交流指正。
什麼是調度器
為了方便剛接觸操作系統和高級語言的同學,先用大白話介紹下什麼是調度器。
調度,是將多個程序合理的安排到有限的CPU上來使得每個程序都能夠得以執行,實現宏觀的並發執行。比如我們的電腦CPU只有四核甚至雙核,可是我們卻可以在電腦上同時運行幾十個程序,這就是操作系統調度器的功勞。但操作系統調度的是進程和線程,線程簡單地說就是輕量級的進程,但是每個線程仍需要MB級別的內存,而且如果兩個切換的線程在不同的進程中,還需要進程切換,會使CPU在調度這件事上花費大量時間。
為了更合理的利用CPU,Golang通過goroutine原生支持高並發,goroutine是由go調度器在語言層面進行調度,將goroutine安排到線程上,可以更充分地利用CPU。
Golang的調度器
Golang的調度器在runtime中實現,我們每個運行的程序執行前都會運行一個runtime負責調度goroutine,我們寫的代碼入口要在main包下的main函數中也是因為runtime.main函數會調用main.main。Golang的調度器在2012被重寫過一次,現在使用的是新版的G-P-M調度器,但是我們還是先來看下老的G-M調度器,這樣才可以更好的體會當前調度器的強大之處。
G-M模型:
下面是舊調度器的G-P模型:
M:代表線程,goroutine都是由線程來執行的;
Global G Queue:全局goroutine隊列,其中G就代表goroutine,所有M都從這個隊列中取出goroutine來執行。
這種模型比較簡單,但是問題也很明顯:
- 多個M訪問一個公共的全局G隊列,每次都需要加互斥鎖保護,造成激烈的鎖競爭和阻塞;
- 局部性很差,即如果M1上的G1創建了G2,需要將G2交給M2執行,但G1和G2是相關的,最好放在同一個M上執行。
- M中有mcache(內存分配狀態),消耗大量內存和較差的局部性。
-
系統調用syscall會阻塞線程,浪費不能合理的利用CPU。
G-P-M模型
後來Go語言開發者改善了調度器為G-P-M模型,如下圖:
其中G還是代表goroutine,M代表線程,全局隊列依然存在;而新增加的P代表邏輯processor,現在G的眼中只有P,在G的眼裡P就是它的CPU。並且給每個P新增加了局部隊列來保存本P要處理的goroutine。
這個模型的調度方法如下:
- 每個P有個局部隊列,局部隊列保存待執行的goroutine
- 每個P和一個M綁定,M是真正的執行P中goroutine的實體
- 正常情況下,M從綁定的P中的局部隊列獲取G來執行
- 當M綁定的P的的局部隊列已經滿了之後就會把goroutine放到全局隊列
- M是復用的,不需要反覆銷毀和創建,擁有work stealing和hand off策略保證線程的高效利用。
- 當M綁定的P的局部隊列為空時,M會從其他P的局部隊列中偷取G來執行,即work stealing;當其他P偷取不到G時,M會從全局隊列獲取到本地隊列來執行G。
- 當G因系統調用(syscall)阻塞時會阻塞M,此時P會和M解綁即hand off,並尋找新的idle的M,若沒有idle的M就會新建一個M。
- 當G因channel或者network I/O阻塞時,不會阻塞M,M會尋找其他runnable的G;當阻塞的G恢復後會重新進入runnable進入P隊列等待執行
- mcache(內存分配狀態)位於P,所以G可以跨M調度,不再存在跨M調度局部性差的問題
- G是搶佔調度。不像操作系統按時間片調度線程那樣,Go調度器沒有時間片概念,G因阻塞和被搶佔而暫停,並且G只能在函數調用時有可能被搶佔,極端情況下如果G一直做死循環就會霸佔一個P和M,Go調度器也無能為力。
Go調度器奇怪的執行順序
是不是感覺自己對Go調度器工作原理已經有個初步的了解了?下面指出一個坑給你踩一下,小心了!
請看下面這段代碼輸出什麼:
func main() { done := make(chan bool) values := []string{"a", "b", "c"} for _, v := range values { fmt.Println("--->", v) go func(u string) { fmt.Println(u) done <- true }(v) } // wait for all goroutines to complete before exiting for _ = range values { <-done } }
先仔細想一下再看答案哦!
實際的數據結果是:
---> a ---> b ---> c c b a
Go調度器示例代碼可以在跟着示例代碼學golang中查看,持續更新中,想系統學習Golang的同學可以關注一下。
可能你的第一反應是「不應該是輸出a,b,c,嗎?為什麼輸出是c,a,b呢?」
這裡我們雖然是使用for循環創建了3個goroutine,而且創建順序是a,b,c,按之前的分析應該是將a,b,c三個goroutine依次放進P的局部隊列,然後按照順序依次執行a,b,c所在的goroutine,為什麼每次都是先執行c所在的goroutine呢?這是因為同一邏輯處理器中三個任務被創建後 理論上會按順序 被放在同一個任務隊列,但實際上最後那個任務會被放在專一的next(下一個要被執行的任務的意思)的位置,所以優先級最高,最可能先被執行,所以表現為在同一個goroutine中創建的多個任務中最後創建那個任務最可能先被執行。
這段解釋來自參考文章《Goroutine執行順序討論》中。
# 調度器狀態的查看方法
GODEBUG這個Go運行時環境變量很是強大,通過給其傳入不同的key1=value1,key2=value2… 組合,Go的runtime會輸出不同的調試信息,比如在這裡我們給GODEBUG傳入了」schedtrace=1000″,其含義就是每1000ms,打印輸出一次goroutine scheduler的狀態。
下面演示使用Golang強大的GODEBUG環境變量可以查看當前程序中Go調度器的狀態:
環境為Windows10的Linux子系統(WSL),WSL搭建和使用的代碼在learn-golang項目有整理,代碼在文末參考的鳥窩的文章中也可以找到。
func main() { var wg sync.WaitGroup wg.Add(10) for i := 0; i < 10; i++ { go work(&wg) } wg.Wait() // Wait to see the global run queue deplete. time.Sleep(3 * time.Second) } func work(wg *sync.WaitGroup) { time.Sleep(time.Second) var counter int for i := 0; i < 1e10; i++ { counter++ } wg.Done() }
編譯指令:
go build 01_GODEBUG-schedtrace.go GODEBUG=schedtrace=1000 ./01_GODEBUG-schedtrace
結果:
SCHED 0ms: gomaxprocs=4 idleprocs=1 threads=5 spinningthreads=1 idlethreads=0 runqueue=0 [4 0 4 0] SCHED 1000ms: gomaxprocs=4 idleprocs=4 threads=8 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0] SCHED 2007ms: gomaxprocs=4 idleprocs=0 threads=8 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 6] SCHED 3025ms: gomaxprocs=4 idleprocs=0 threads=8 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 6] SCHED 4033ms: gomaxprocs=4 idleprocs=0 threads=8 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 6] SCHED 5048ms: gomaxprocs=4 idleprocs=0 threads=8 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 6] SCHED 6079ms: gomaxprocs=4 idleprocs=0 threads=8 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 6] SCHED 7081ms: gomaxprocs=4 idleprocs=0 threads=8 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 6] SCHED 8092ms: gomaxprocs=4 idleprocs=0 threads=8 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 6] SCHED 9113ms: gomaxprocs=4 idleprocs=0 threads=8 spinningthreads=0 idlethreads=3 runqueue=0 [0 1 0 1] SCHED 10129ms: gomaxprocs=4 idleprocs=0 threads=8 spinningthreads=0 idlethreads=3 runqueue=0 [0 1 0 1] SCHED 11134ms: gomaxprocs=4 idleprocs=0 threads=8 spinningthreads=0 idlethreads=3 runqueue=0 [0 1 0 1] SCHED 12157ms: gomaxprocs=4 idleprocs=0 threads=8 spinningthreads=0 idlethreads=3 runqueue=0 [0 1 0 1] SCHED 13170ms: gomaxprocs=4 idleprocs=0 threads=8 spinningthreads=0 idlethreads=3 runqueue=0 [0 1 0 1] SCHED 14183ms: gomaxprocs=4 idleprocs=0 threads=8 spinningthreads=0 idlethreads=3 runqueue=0 [0 1 0 1] SCHED 15187ms: gomaxprocs=4 idleprocs=0 threads=8 spinningthreads=0 idlethreads=3 runqueue=0 [0 1 0 1] SCHED 16187ms: gomaxprocs=4 idleprocs=2 threads=8 spinningthreads=0 idlethreads=5 runqueue=0 [0 0 0 0] SCHED 17190ms: gomaxprocs=4 idleprocs=2 threads=8 spinningthreads=0 idlethreads=5 runqueue=0 [0 0 0 0] SCHED 18193ms: gomaxprocs=4 idleprocs=2 threads=8 spinningthreads=0 idlethreads=5 runqueue=0 [0 0 0 0] SCHED 19196ms: gomaxprocs=4 idleprocs=2 threads=8 spinningthreads=0 idlethreads=5 runqueue=0 [0 0 0 0] SCHED 20200ms: gomaxprocs=4 idleprocs=4 threads=8 spinningthreads=0 idlethreads=6 runqueue=0 [0 0 0 0] SCHED 21210ms: gomaxprocs=4 idleprocs=4 threads=8 spinningthreads=0 idlethreads=6 runqueue=0 [0 0 0 0] SCHED 22219ms: gomaxprocs=4 idleprocs=4 threads=8 spinningthreads=0 idlethreads=6 runqueue=0 [0 0 0 0]
看到怎麼多輸出不要慌, 了解每個字段的含義就很清晰了:
- SCHED 1000ms
自程序運行開始經歷的時間 - gomaxprocs=4
當前程序使用的邏輯processor,即P,小於等於CPU的核數。 - idleprocs=4
空閑的線程數 - threads=8
當前程序的總線程數M,包括在執行G的和空閑的 - spinningthreads=0
處於自旋狀態的線程,即M在綁定的P的局部隊列和全局隊列都沒有G,M沒有銷毀而是在四處尋覓有沒有可以steal的G,這樣可以減少線程的大量創建。 - idlethreads=3
處於idle空閑狀態的線程 - runqueue=0
全局隊列中G的數目 - [0 0 0 6]
本地隊列中的每個P的局部隊列中G的數目,我的電腦是四核所有有四個P。
上面的輸出信息已經足夠我們了解我們的程序運行狀況,要想看每個goroutine、m和p的詳細調度信息,可以在GODEBUG時加入,scheddetail
:
GODEBUG=schedtrace=1000,scheddetail=1 ./01_GODEBUG-schedtrace
結果如下:
SCHED 0ms: gomaxprocs=4 idleprocs=4 threads=7 spinningthreads=0 idlethreads=2 runqueue=0 gcwaiting=0 nmidlelocked=0 stopwait=0 sysmonwait=0 P0: status=0 schedtick=7 syscalltick=1 m=-1 runqsize=0 gfreecnt=0 P1: status=0 schedtick=2 syscalltick=1 m=-1 runqsize=0 gfreecnt=0 P2: status=0 schedtick=1 syscalltick=1 m=-1 runqsize=0 gfreecnt=0 P3: status=0 schedtick=1 syscalltick=1 m=-1 runqsize=0 gfreecnt=0 M6: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=true lockedg=-1 M5: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=true lockedg=-1 M4: p=-1 curg=33 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=true lockedg=-1 M3: p=-1 curg=49 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=true lockedg=-1 M2: p=-1 curg=17 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=true lockedg=-1 M1: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=1 dying=0 spinning=false blocked=false lockedg=-1 M0: p=-1 curg=14 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=true lockedg=-1 G1: status=4(semacquire) m=-1 lockedm=-1 G2: status=4(force gc (idle)) m=-1 lockedm=-1 G3: status=4(GC sweep wait) m=-1 lockedm=-1 G4: status=4(sleep) m=-1 lockedm=-1 G5: status=4(sleep) m=-1 lockedm=-1 G6: status=4(sleep) m=-1 lockedm=-1 G7: status=4(sleep) m=-1 lockedm=-1 G8: status=4(sleep) m=-1 lockedm=-1 G9: status=4(sleep) m=-1 lockedm=-1 G10: status=4(sleep) m=-1 lockedm=-1 G11: status=4(sleep) m=-1 lockedm=-1 G12: status=4(sleep) m=-1 lockedm=-1 G13: status=4(sleep) m=-1 lockedm=-1 G14: status=3() m=0 lockedm=-1 G33: status=3() m=4 lockedm=-1 G17: status=3() m=2 lockedm=-1 G49: status=3() m=3 lockedm=-1
代碼可以在跟着示例代碼學golang中查看,持續更新中,想系統學習Golang的同學可以關注一下。
參考資料: