原創文章,歡迎轉載,轉載請註明出處,謝謝。
0. 前言
前面介紹了執行時間過長和系統呼叫引起的搶佔,它們都屬於協作式搶佔。本講會介紹基於訊號的真搶佔式排程。
在介紹真搶佔式排程之前看下 Go 的兩種搶佔式排程器:
搶佔式排程器 - Go 1.2 至今
- 基於協作的搶佔式排程器 - Go 1.2 - Go 1.13
改進:透過編譯器在函式呼叫時插入搶佔檢查指令,在函式呼叫時檢查當前 Goroutine 是否發起了搶佔請求,實現基於協作的搶佔式排程。
缺陷:Goroutine 可能會因為垃圾收集和迴圈長時間佔用資源導致程式暫停。 - 基於訊號的搶佔式排程器 - Go 1.14 至今
改進:實現了基於訊號的真搶佔式排程。
缺陷 1:垃圾收集在掃描棧時會觸發搶佔式排程。
缺陷 2:搶佔的時間點不夠多,不能覆蓋所有邊緣情況。
(注:該段文字來源於 搶佔式排程器)
協作式搶佔是透過在函式呼叫時插入 搶佔檢查 來實現搶佔的,這種搶佔的問題在於,如果 goroutine 中沒有函式呼叫,那就沒有辦法插入 搶佔檢查,導致無法搶佔。我們看 Go runtime 排程器精講(七):案例分析 的示例:
//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=1 go run main.go
程式會卡死。這是因為在 gpm 前插入 //go:nosplit
會禁止函式棧擴張,協作式搶佔不能在函式棧呼叫前插入 搶佔檢查,導致這個 goroutine 沒辦法被搶佔。
而基於訊號的真搶佔式排程可以改善這個問題。
1. 基於訊號的真搶佔式排程
這裡我們說的非同步搶佔指的就是基於訊號的真搶佔式排程。
非同步搶佔的實現在 :
func preemptone(pp *p) bool {
...
// Request an async preemption of this P.
if preemptMSupported && debug.asyncpreemptoff == 0 {
pp.preempt = true
preemptM(mp) // 非同步搶佔
}
return true
}
進入 preemptM
:
func preemptM(mp *m) {
...
if mp.signalPending.CompareAndSwap(0, 1) { // 更新 signalPending
signalM(mp, sigPreempt) // signalM 給執行緒發訊號
}
...
}
// signalM sends a signal to mp.
func signalM(mp *m, sig int) {
tgkill(getpid(), int(mp.procid), sig)
}
func tgkill(tgid, tid, sig int)
呼叫 signalM
給執行緒發 sigPreempt(_SIGURG:23)訊號。執行緒接收到該訊號會做相應的處理。
1.1 執行緒處理搶佔訊號
執行緒是怎麼處理作業系統發過來的 sigPreempt 訊號的呢?
執行緒的訊號處理在 sighandler:
func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {\
// The g executing the signal handler. This is almost always
// mp.gsignal. See delayedSignal for an exception.
gsignal := getg()
mp := gsignal.m
if sig == sigPreempt && debug.asyncpreemptoff == 0 && !delayedSignal {
// Might be a preemption signal.
doSigPreempt(gp, c)
// Even if this was definitely a preemption signal, it
// may have been coalesced with another signal, so we
// still let it through to the application.
}
...
}
進入 doSigPreempt
:
// doSigPreempt handles a preemption signal on gp.
func doSigPreempt(gp *g, ctxt *sigctxt) {
// Check if this G wants to be preempted and is safe to
// preempt.
if wantAsyncPreempt(gp) {
if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok {
// Adjust the PC and inject a call to asyncPreempt.
ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), newpc)
}
}
// Acknowledge the preemption.
gp.m.preemptGen.Add(1)
gp.m.signalPending.Store(0)
}
首先,doSigPreempt
呼叫 wantAsyncPreempt
判斷是否做非同步搶佔:
// wantAsyncPreempt returns whether an asynchronous preemption is
// queued for gp.
func wantAsyncPreempt(gp *g) bool {
// Check both the G and the P.
return (gp.preempt || gp.m.p != 0 && gp.m.p.ptr().preempt) && readgstatus(gp)&^_Gscan == _Grunning
}
如果是,繼續呼叫 isAsyncSafePoint 判斷當前執行的是不是非同步安全點,執行緒只有執行到非同步安全點才能處理非同步搶佔。安全點是指 Go 執行時認為可以安全地暫停或搶佔一個正在執行的 Goroutine 的位置。非同步搶佔的安全點確保 Goroutine 在被暫停或切換時,系統的狀態是穩定和一致的,不會出現資料競爭、死鎖或未完成的重要計算。
如果是非同步搶佔的安全點。則呼叫 ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), newpc)
執行 asyncPreempt
:
// asyncPreempt saves all user registers and calls asyncPreempt2.
//
// When stack scanning encounters an asyncPreempt frame, it scans that
// frame and its parent frame conservatively.
//
// asyncPreempt is implemented in assembly.
func asyncPreempt()
//go:nosplit
func asyncPreempt2() { // asyncPreempt 會呼叫到 asyncPreempt2
gp := getg()
gp.asyncSafePoint = true
if gp.preemptStop {
mcall(preemptPark) // 搶佔型別,如果是 preemptStop 則執行 preemptPark 搶佔
} else {
mcall(gopreempt_m)
}
gp.asyncSafePoint = false
}
asyncPreempt
呼叫 asyncPreempt2
處理 gp.preemptStop
和非 gp.preemptStop
的搶佔。對於非 gp.preemptStop
的搶佔,我們在 Go runtime 排程器精講(八):執行時間過長的搶佔 也介紹過,主要內容是將執行時間過長的 goroutine 放到全域性佇列中。接著執行緒執行排程獲取下一個可執行的 goroutine。
1.2 案例分析
還記得在 Go runtime 排程器精講(七):案例分析 中最後留下的思考嗎?
//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
為什麼開啟非同步搶佔,程式還是會卡死?
從前面的分析結合我們的 dlv debug
發現,在安全點判斷 isAsyncSafePoint
這裡總是返回 false,無法進入 asyncpreempt
搶佔該 goroutine。並且,由於協作式搶佔的搶佔點檢查被 //go:nosplit
禁用了,導致協作式和非同步搶佔都無法搶佔該 goroutine。
2. 小結
本講介紹了非同步搶佔,也就是基於訊號的真搶佔式排程。至此,我們的 Go runtime 排程器精講基本結束了,透過十講內容大致理解了 Go runtime 排程器在做什麼。下一講,會總覽全域性,把前面講的內容串起來。