1. 程式人生 > 程式設計 >Go排程器介紹和容易忽視的問題

Go排程器介紹和容易忽視的問題

本文記錄了本人對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,c,按之前的分析應該是將a,c三個goroutine依次放進P的區域性佇列,然後按照順序依次執行a,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執行順序討論