Go runtime 排程器精講(十):非同步搶佔

胡云Troy發表於2024-09-16

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


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 排程器在做什麼。下一講,會總覽全域性,把前面講的內容串起來。


相關文章