Go runtime 排程器精講(七):案例分析

胡云Troy發表於2024-09-15

原創文章,歡迎轉載,轉載請註明出處,謝謝。


0. 前言

前面用了六講介紹 Go runtime 排程器,這一講我們看一個關於排程 goroutine 的程式案例分析下排程器做了什麼。需要說明的是,這個程式和搶佔有關,搶佔目前為止還沒有介紹到,如果看不懂也沒有關係,有個印象就行。

1. 案例 1

執行程式碼:

func gpm() {
	var x int
	for {
		x++
	}
}

func main() {
	var x int
	threads := runtime.GOMAXPROCS(0)
	for i := 0; i < threads; i++ {
		go gpm()
	}

	time.Sleep(1 * time.Second)
	fmt.Println("x = ", x)
}

執行程式:

# go run main.go 
x =  0

(為什麼輸出 x=0 和本系列內容無關,這裡直接跳過)

Go 在 1.14 版本引入了非同步搶佔機制,我們使用的是 1.21.0 版本的 Go,預設開啟非同步搶佔。透過 asyncpreemptoff 標誌可以開啟/禁用非同步搶佔,asyncpreemptoff=1 表示禁用非同步搶佔,相應的 asyncpreemptoff=0 表示開啟非同步搶佔。

1.1 禁用非同步搶佔

首先,禁用非同步搶佔,再次執行上述程式碼:

# GODEBUG=asyncpreemptoff=1 go run main.go

程式卡死,無輸出。檢視 CPU 使用率:

top - 10:08:53 up 86 days, 10:48,  0 users,  load average: 3.08, 1.29, 0.56
Tasks: 179 total,   2 running, 177 sleeping,   0 stopped,   0 zombie
%Cpu(s): 74.4 us,  0.6 sy,  0.0 ni, 25.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem :  20074.9 total,   4279.4 free,   3118.3 used,  12677.2 buff/cache
MiB Swap:      0.0 total,      0.0 free,      0.0 used.  16781.0 avail Mem 

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND                                 
1014008 root      20   0 1226288    944    668 R 293.7   0.0   5:35.81 main             // main 是執行的程序

CPU 佔用率高達 293.7,太高了。

為什麼會出現這樣的情況呢?我們可以透過 GODEBUG=schedtrace=1000,scheddetail=1,asyncpreemptoff=1 列印程式執行的 G,P,M 資訊,透過 DEBUG 輸出檢視排程過程中發生了什麼。

當建立和執行緒數相等的 goroutine 後,執行緒執行 main goroutine。runtime(實際是 sysmon 執行緒,後文會講)發現 main goroutine 執行時間過長,把它排程走,執行其它 goroutine(這是主動排程的邏輯,不屬於非同步搶佔的範疇)。接著執行和執行緒數相等的 goroutine,這幾個 goroutine 是永不退出的,執行緒會一直執行,佔滿邏輯核。

解決這個問題,我們改動程式碼如下:

func main() {
	var x int
	threads := runtime.GOMAXPROCS(0)
	for i := 0; i < threads; i++ {
		go gpm()
	}

	time.Sleep(1 * time.Nanosecond)
	fmt.Println("x = ", x)
}

因為 main goroutine 執行時間過長,被 runtime 排程走。我們把休眠時間設成 1 納秒,不讓它睡那麼長。接著執行程式:

# GODEBUG=asyncpreemptoff=1 go run main.go 
x =  0

程式退出。天下武功唯快不破啊,main goroutine 直接執行完退出,不給 runtime 反應的機會。

還有其它改法嗎?我們在 gpm 中加上 time.Sleep 函式呼叫:

func gpm() {
	var x int
	for {
		time.Sleep(1 * time.Nanosecond)
		x++
	}
}

func main() {
	var x int
	threads := runtime.GOMAXPROCS(0)
	for i := 0; i < threads; i++ {
		go gpm()
	}

	time.Sleep(1 * time.Second)
	fmt.Println("x = ", x)
}

執行程式:

# GODEBUG=asyncpreemptoff=1 go run main.go 
x =  0

也是正常退出。為什麼加上函式呼叫就可以呢?這和搶佔的邏輯有關,因為有了函式呼叫,就有機會在函式序言部分設定“搶佔標誌”,執行搶佔 goroutine 的排程(同樣的,後面會詳細講)。

要注意這裡 time.Sleep(1 * time.Nanosecond) 加的位置,如果加在這裡:

func gpm() {
	var x int
	time.Sleep(1 * time.Nanosecond)
	for {
		x++
	}
}

程式還是會卡死。

我們討論了半天 asyncpreemptoff=1 禁止非同步搶佔的情況。是時候開啟非同步搶佔看看輸出結果了。

1.2 開啟非同步搶佔

程式還是那個程式:

func gpm() {
	var x int
	for {
		x++
	}
}

func main() {
	var x int
	threads := runtime.GOMAXPROCS(0)
	for i := 0; i < threads; i++ {
		go gpm()
	}

	time.Sleep(1 * time.Second)
	fmt.Println("x = ", x)
}

開啟非同步搶佔執行:

# GODEBUG=asyncpreemptoff=0 go run main.go 
x =  0

非同步搶佔就可以了,為啥非同步搶佔就可以了呢?非同步搶佔透過給執行緒發訊號的方式,使得執行緒在“安全點”執行非同步搶佔的邏輯(後面幾講會介紹非同步搶佔的邏輯)。

再次改寫程式碼如下:

//go:nosplit
func gpm() {
	var x int
	for {
		x++
	}
}

func main() {
	var x int
	threads := runtime.GOMAXPROCS(0)
	for i := 0; i < threads; i++ {
		go gpm()
	}

	time.Sleep(1 * time.Second)
	fmt.Println("x = ", x)
}

同樣的執行輸出:

# GODEBUG=asyncpreemptoff=0 go run main.go 

程式又卡死了...

這個程式就當思考題吧,為什麼加個 //go:nosplit 程式就卡死了呢?

2. 小結

本講不是為了湊字數,主要是為引入後續的搶佔做個鋪墊,下一講會介紹執行時間過長的搶佔排程。


相關文章