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來執行。
這種模型比較簡單,但是問題也很明顯:

  1. 多個M訪問一個公共的全局G隊列,每次都需要加互斥鎖保護,造成激烈的鎖競爭和阻塞;
  2. 局部性很差,即如果M1上的G1創建了G2,需要將G2交給M2執行,但G1和G2是相關的,最好放在同一個M上執行。
  3. M中有mcache(內存分配狀態),消耗大量內存和較差的局部性。
  4. 系統調用syscall會阻塞線程,浪費不能合理的利用CPU。

    G-P-M模型

    後來Go語言開發者改善了調度器為G-P-M模型,如下圖:

其中G還是代表goroutine,M代表線程,全局隊列依然存在;而新增加的P代表邏輯processor,現在G的眼中只有P,在G的眼裡P就是它的CPU。並且給每個P新增加了局部隊列來保存本P要處理的goroutine。
這個模型的調度方法如下:

  1. 每個P有個局部隊列,局部隊列保存待執行的goroutine
  2. 每個P和一個M綁定,M是真正的執行P中goroutine的實體
  3. 正常情況下,M從綁定的P中的局部隊列獲取G來執行
  4. 當M綁定的P的的局部隊列已經滿了之後就會把goroutine放到全局隊列
  5. M是復用的,不需要反覆銷毀和創建,擁有work stealing和hand off策略保證線程的高效利用。
  6. 當M綁定的P的局部隊列為空時,M會從其他P的局部隊列中偷取G來執行,即work stealing;當其他P偷取不到G時,M會從全局隊列獲取到本地隊列來執行G。
  7. 當G因系統調用(syscall)阻塞時會阻塞M,此時P會和M解綁即hand off,並尋找新的idle的M,若沒有idle的M就會新建一個M。
  8. 當G因channel或者network I/O阻塞時,不會阻塞M,M會尋找其他runnable的G;當阻塞的G恢復後會重新進入runnable進入P隊列等待執行
  9. mcache(內存分配狀態)位於P,所以G可以跨M調度,不再存在跨M調度局部性差的問題
  10. 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的同學可以關注一下。

參考資料:

大彬Go調度器系列

也談goroutine調度器

鳥窩 Go調度器跟蹤

Go調度器詳解

Goroutine執行順序討論