Go 併發程式設計 - runtime 協程排程(三)

燈火消逝的碼頭發表於2023-11-01

Go Runtime

Go runtime 可以形象的理解為 Go 程式執行時的環境,類似於 JVM。不同於 JVM 的是,Go 的 runtime 與業務程式直接打包在一塊,是一個可執行檔案,直接執行在作業系統上,效率很高。

runtime 包含了一些 Go 的一些非常核心的功能:協程排程、垃圾回收、記憶體分配等。本文將著重介紹協程排程(GMP 模型)。

協程排程

協程排程是指 Go 如何管理和執行協程,Go 的協程排程基於 GMP 模型。即:

  • G (Goroutine):即 Go 的協程,包含了棧資訊,程式碼指標,狀態等;
  • M (Machine):代表一個工作執行緒,由作業系統直接分配;
  • P (Processor):處理器(Go 定義的一個概念,不是指 CPU),包含了協程執行的所需資源,如本地佇列、全域性佇列、計數器等。

GMP 三者的關係:

  • P 的個數取決於設定的 runtime.GOMAXPROCS,預設是物理 CPU 的邏輯核心數量,比如四核八執行緒的 CPU,P 的數量就是 8;
  • M 的數量一般是多於 P 的,M 要想被 CPU 執行,必須先獲取 P。沒有獲取 P 的 M,則處於休眠狀態;
  • G 可以理解為程式碼本體,G 必須要被 P 排程進入 M,才可以被 CPU 執行;
  • P 包含了一個 LRQ (Local Run Queue)本地執行佇列,儲存著等待執行的協程(G)佇列。沒有被分配到 P 的 G,會被儲存到 GRQ (Global Run Queue) 全域性佇列中,處於休眠狀態。

假如主機是單邏輯 CPU 的,那麼 GMP 是這樣的:

Go 併發程式設計 - runtime 協程排程(三)

紅色部分表示休眠或者掛起狀態,黃色代表等待執行,綠色表示正在執行。系統初始化了兩個執行緒,但我們只有一個處理器(P), M1 沒有獲取到 P,所以只能休眠。M0 當前獲取到 P ,正在處理 G0, LRQ 裡面目前有三個 G 在排隊等待被 M 執行,GRQ 裡面儲存著 G4、G5、G6,表示它們還沒有分配到佇列中。

P 這個時候會分別對 LRQ 進行週期佇列輪轉 和 GRO 週期性檢查:

  • 佇列輪轉:LRQ 中的 G 被 P 排程到 M 中執行,每個 G 執行一段時間後,就會儲存其上下文並放入佇列尾部,然後取出佇列頭部的 G 進入 M 執行。
  • 週期性檢查:P 會檢查 GRQ 中是否有 G 正在等待執行,並將其放入 M 中執行,防止協程被餓死。
  • 在佇列輪轉中,如果當前正在執行的 G 遇到了系統呼叫,那麼系統就會掛起當前 M0,釋放 P,M1 就會繫結釋放的 P,來繼續執行其他協程。

假設 G0 遇到了系統呼叫:

Go 併發程式設計 - runtime 協程排程(三)

等到 M1 中所有的協程執行完或者 M1 處理某個協程也遇到了了系統呼叫,就會重新釋放 P 給其他空閒的 M。而另外一邊 G0 的系統呼叫結束後,就會將 M0 執行緒從掛起狀態變成休眠狀態,並將 G0 放入 GRQ,等待被 P 重新調入 LRQ 中輪轉執行。

如果我們的主機具備多個邏輯 CPU,建立了多個 P,那麼就會變成多個執行緒並行執行:

Go 併發程式設計 - runtime 協程排程(三)

多執行緒同時處理時,很有可能多個 LRQ 是不均衡的。假如上圖的 M0 已經執行完了,其他執行緒還處於繁忙狀態,M0 所繫結的 P 就會去檢查 GQR,GQR 中也沒有 G,那麼它就會去偷取其他 LRQ 一部分的 G 來執行,一般每次會偷取一半。

runtime 包

runtime.GOMAXPROCS

 runtime.GOMAXPROCS() 可以用來設定 P 的數量,一般設定為和邏輯 CPU  數量相等的值:

fmt.Println(runtime.NumCPU())
runtime.GOMAXPROCS(runtime.NumCPU()) // 使用所有的邏輯 CPU

// 結果
我的主機 CPU 是16核24執行緒,所以會使用24個 P

runtime.Gosched

runtime.Gosched() 用於讓出當前協程的執行時間片,也就是當 P 遇到它時,會先安排其他協程先執行:

func main() {
	runtime.GOMAXPROCS(1)
	go func() {
		for i := 0; i < 5; i++ {
			fmt.Println("go")
		}
	}()

	runtime.Gosched()
	fmt.Println("hello")
}


// 結果
輸入結果不是固定的,有可能是
go
go
go
go
go
hello
也有可能是
go
go
go
go
hello
go
也有可能是
hello

輸出第一種情況容易理解,主協程讓出了時間片,理所應當先列印 Go,但是如果子協程還沒有來得及被排程或者列印,就會出現其他情況。

runtime.Goexit

runtime.Goexit() 會結束當前的協程,但是 defer 語句會正常執行。此語法不能在主函式中使用,會引發 panic:

func main() {
	runtime.GOMAXPROCS(1)
	go func() {
		defer fmt.Println("defer不受影響")
		fmt.Println("我被執行了")
		runtime.Goexit()
		fmt.Println("我被跳過了")
	}()

	time.Sleep(1 * time.Second)
}

// 結果
我被執行了
defer不受影響

本系列文章:

  1. Go 併發程式設計 - Goroutine 基礎 (一)
  2. Go 併發程式設計 - 併發安全(二)
  3. Go 併發程式設計 - runtime 協程排程(三)

相關文章