原創文章,歡迎轉載,轉載請註明出處,謝謝。
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. 小結
本講不是為了湊字數,主要是為引入後續的搶佔做個鋪墊,下一講會介紹執行時間過長的搶佔排程。