go 原始碼分析 goroutine 概覽與排程

zp0int發表於2020-11-01

goroutine

以後都在 github 更新,請戳 Goroutine–概覽與排程

目錄

相關位置檔案

概覽

排程

為什麼

更多資料

相關位置檔案

  • src/runtime/runtime2.go
  • src/runtime/proc.go
  • src/plugin/plugin_dlopen.go

概覽

如果你對 MPG 在Go協程排程中代表什麼並且是如何工作的 感到疑惑, 請先參考 更多資料 中的 scheduling-in-go-part1scheduling-in-go-part3

根據上述文章以及 Go 原始碼中的註釋

M

作業系統執行緒, ‘M’ 是 machine 的簡寫, 這個執行緒其實是被作業系統排程的, 並且作業系統仍然負責把執行緒排程到某個核心上執行

P

邏輯處理器, 執行 Go 程式碼的時候, 必須要獲得的資源. M 必須擁有與之相關聯的 P 才能執行 Go 程式碼. 但是 M 不論在是否存在與之對應的 P 的時候, 都能被阻塞住或者卡在系統呼叫中

G

一個 Goroutine 就是一個 協程, 但是這是 Go, 所以我們把字母 “C”(Coroutine) 替換成 “G”(Goroutine), 你可以把 Goroutine 當成應用級別的執行緒, 他們和系統級別的執行緒在很多地方都非常相似, 只是說作業系統的執行緒是在 CPU 核心上進行上下文切換(切入切出), 而 Goroutine 是在 M 上進行上下文切換(切入切出)

mpg

圖片來自 scheduling-in-go-part2

# GOSSAFUNC=main GOOS=linux GOARCH=amd64 go build -gcflags "-S" simple.go
# GOSSAFUNC=main go_dev build -gcflags "-S" num_cpu.go
# GODEBUG=schedtrace=DURATION,gctrace=1 go_dev run num_cpu.go
# GODEBUG=schedtrace=DURATION go_dev run num_cpu.go
find . -name '*.go' -exec grep -nHr 'inittask' {} \;

bootstrap 流程的一部分會在預設的 M(M0) 上執行一個 G, 這個 G 會執行 runtime.mstart, runtime.mstart 最終會進入 schedule 函式, schedule 函式會把 G1 排程到當前的 M(M0) 上執行, G1 會進入 runtime/proc.gomain 函式, main 中的 doInit(&runtime_inittask) 會預設初始化並生成至多 N 個 M(系統執行緒), N 是CPU核心數目, 在這之後, G1 執行到函式的末尾並執行 exit(0)

實際上, 在 runtime_inittask 之前, 就會有一個新的 M(thread) 被生成, 一個 goroutine 會在 M 上執行 sysmon 函式, 之後 runtime_inittask 才會生成最多到 N 個 M(執行緒)

我們以更直觀的圖表來展示一下

bootstrap

對於 M1MN 中的每個 M, 都會有一個新的 G 在上面執行, 並且每個 M 上都會執行到 runtime.mstart, 通過 runtime.mstart 最終都會進入 schedule 函式並且讓排程器進行排程

我沒有在原始碼中的 goc 檔案中找到 runtime_inittask, 但是我在 src/plugin/plugin_dlopen.go 中找到了從 c 動態連結庫中裝載 inittask 的方法, 也許他在 src/runtime/asm_amd64.s 這個彙編程式碼中, 後續找到再來研究這一塊內容(如果你在我之前找到了, 歡迎提 pull request)

排程

可以在 src/runtime/proc.go 找到 schedule 函式的定義

// 執行一輪排程: 找到一個可執行的 goroutine 並執行它
// 這個函式不會返回
func schedule() {
		// ...
  	if gp == nil && gcBlackenEnabled != 0 {
		gp = gcController.findRunnableGCWorker(_g_.m.p.ptr())
		tryWakeP = tryWakeP || gp != nil
		}
  	if gp == nil {
		// 隔一段時間檢查一下全域性 goroutine 佇列, 來保證公平
		// 不然的話, 兩個在本地佇列的 goroutine 可以通過迴圈互相產生對方作為一個新的 goroutine 的方式, 來讓本地佇列永遠有任務執行
		if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
			lock(&sched.lock)
			gp = globrunqget(_g_.m.p.ptr(), 1)
			unlock(&sched.lock)
		}
	}
	if gp == nil {
		gp, inheritTime = runqget(_g_.m.p.ptr())
	}
	if gp == nil {
		gp, inheritTime = findrunnable() // 這個呼叫會阻塞, 知道有任務可以執行為止才返回
	}
	// ...
  execute(gp, inheritTime)
}

排程的流程就比較清晰了

(對於每個P)每隔61次呼叫排程之後, 就會嘗試從全域性佇列裡取一個任務出來, 預設情況則是從本地佇列取一個任務出來(當前P對應的佇列中)

最後一部分的謎團就是執行佇列(run queues), Go 的排程系統中有兩種不同的執行佇列, 全域性執行佇列(Global Run Queue/GRQ)和本地執行佇列(Local Run Queue/LRQ), 每個 P 身上儲存了一個對應的 LRQ 資料結構, 這個結構用來管理並儲存 Goroutine, 這裡的 Goroutine 執行時也需要依賴 P 的環境. 這些 P 身上儲存的 Goroutine 輪流從 M 上面切換上去/切換出來(上下文切換). GRQ 是用來儲存還沒有對應的 P 的 Goroutine 的, 後續會討論一個流程是把 Goroutine 從 GRQ 移動到 LRQ 的 (我: 上面程式碼中已經展示了)

翻譯自 scheduling-in-go-part2

schedule

schedule 中, findrunnable 會找到一個在當前 P 中可以執行的 goroutine, 本地佇列是儲存在 P 內部的

為什麼

對於作業系統級別的執行緒(M)在 CPU 中進行上下文切換

每一次上下文切換都會導致潛在的約 1000 納秒的延時, 假設硬體的執行速度是每納秒 12 個 CPU 指令, 你會發現線上程切換的時候本來可以執行 12000 個指令, 但是現在並沒有執行, 並且執行緒還可能在 CPU 不同的核心間切換, CPU 快取在這種情況下會失效, 會導致更大的執行緒切換開銷, 更高的延時

對於使用者級別的協程(Goroutinue/G) 在 M 中進行上下文切換, 從作業系統的角度上看, 只是同一個執行緒在不停地執行指令而已, 並且只要你的 Goroutinue 有足夠多的工作要做, 作業系統會認為你是一個 CPU 密集型的執行緒

本質上, Go 把你的 IO/阻塞型 應用在作業系統層面變成了 CPU 密集型應用. 因為所有的上下文切換都發生在了應用層, 當我們通過執行緒執行程式時, 我們不會平均每次上下文切換都丟失大約 12000 個指令的執行時間. 在 Go 裡, 相似的上下文切換佔用大約 200 納秒, 或者 2400 個指令, 並且排程器排程時會考慮到 CPU 快取以及 NUMA. 這也是為什麼我們生成的執行緒數不需大於我們的核心數的原因

在 Go 中, 隨著程式的執行, 我們可以認為更多的任務會被執行並且完成, 因為 Go 排程器會嘗試用盡可能少的執行緒並且在每個執行緒中執行儘可能多的任務(協程), 通過這種方式降低作業系統和硬體的開銷

翻譯自 scheduling-in-go-part2

更多資料

scheduling-in-go-part1

scheduling-in-go-part2

scheduling-in-go-part3

how-a-go-program-compiles-down-to-machine-code

How to call private functions (bind to hidden symbols) in GoLang

相關文章