Go runtime 排程器精講(八):sysmon 執行緒和 goroutine 執行時間過長的搶佔

胡云Troy發表於2024-09-16

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


0. 前言

Go runtime 排程器精講(七):案例分析 一文我們介紹了一個搶佔的案例。從案例分析搶佔的實現,並未涉及到原始碼層面。本文將繼續從原始碼入手,看 Go runtime 排程器是如何實現搶佔邏輯的。

1. sysmon 執行緒

還記得 Go runtime 排程器精講(四):執行 main goroutine 一文我們蜻蜓點水的提了一嘴 sysmon 執行緒,它是執行在系統棧上的監控執行緒,負責監控 goroutine 的狀態,並且做相應處理。當然,也負責做搶佔的處理,它是本講的重點。

image

sysmon 的建立在 src/runtime/proc.go:sysmon

// The main goroutine.
func main() {
	...
	if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon
		systemstack(func() {
			newm(sysmon, nil, -1)
		})
	}
    ...
}

sysmon 不需要和 P 繫結,作為監控執行緒執行在系統棧。進入 sysmon

func sysmon() {
	...
    idle := 0 // how many cycles in succession we had not wokeup somebody
	delay := uint32(0)

	for {
		if idle == 0 { // start with 20us sleep...
			delay = 20  // 
		} else if idle > 50 { // start doubling the sleep after 1ms...
			delay *= 2
		}
		if delay > 10*1000 { // up to 10ms
			delay = 10 * 1000
		}
		usleep(delay)           // 休眠 delay us

        // retake P's blocked in syscalls
		// and preempt long running G's
		if retake(now) != 0 {
			idle = 0
		} else {
			idle++
		}
        ...
    }
}

省略了很多和搶佔無關的內容,和搶佔相關的是 retake 函式,進入 retake

func retake(now int64) uint32 {
	n := 0
	lock(&allpLock)
    
    // 。。。
	for i := 0; i < len(allp); i++ {
        if pp == nil {
			// This can happen if procresize has grown
			// allp but not yet created new Ps.
			continue
		}

        pd := &pp.sysmontick                                        // 用於 sysmon 執行緒記錄被監控 p 的系統呼叫次數和呼叫時間
		s := pp.status
		sysretake := false
		if s == _Prunning || s == _Psyscall {                       // 如果 P 是 _Prunning 或者 _Psyscall,則對 P 進行處理
			// Preempt G if it's running for too long.
			t := int64(pp.schedtick)                                // P 的 schedtick 用於記錄 P 被排程的次數
			if int64(pd.schedtick) != t {                           
				pd.schedtick = uint32(t)                            // 如果系統監控和排程次數不一致,則更新系統監控的排程次數和排程時間點
				pd.schedwhen = now
			} else if pd.schedwhen+forcePreemptNS <= now {          // forcePreemptNS 為 10ms,如果 P 的 goroutine 執行時間超過 10ms 則對 P 發起搶佔
				preemptone(pp)                                      // 搶佔 P
				// In case of syscall, preemptone() doesn't
				// work, because there is no M wired to P.
				sysretake = true                                    // 設定 retake 標誌為 true
			}
		}
        ...
    }
    unlock(&allpLock)
	return uint32(n)
}

這裡重點在如果 P 的 goroutine 執行時間過長,則進入 preemptone(pp) 搶佔 P,也就是搶佔執行時間過長的 goroutine。

1.1 搶佔執行時間過長的 goroutine

進入 preemptone

func preemptone(pp *p) bool {
	mp := pp.m.ptr()                                                // P 繫結的執行緒 
	if mp == nil || mp == getg().m {
		return false
	}
	gp := mp.curg                                                   // 執行緒執行的 goroutine,就是該 goroutine 執行過長的
	if gp == nil || gp == mp.g0 {
		return false
	}

    gp.preempt = true                                               // 設定搶佔標誌位為 true

    // Every call in a goroutine checks for stack overflow by
	// comparing the current stack pointer to gp->stackguard0.
	// Setting gp->stackguard0 to StackPreempt folds
	// preemption into the normal stack overflow check.
	gp.stackguard0 = stackPreempt                                   // 官方的註釋已經很清晰了,設定 goroutine 的 stackguard0 為 stackPreempt,stackPreempt 是一個比任何棧都大的數

    // Request an async preemption of this P.
	if preemptMSupported && debug.asyncpreemptoff == 0 {            // 是否開啟非同步搶佔,這裡我們先忽略
		pp.preempt = true
		preemptM(mp)
	}

	return true
}

可以看到,preemptone 主要是更新了 goroutine 的 gp.stackguard0,為什麼更新這個呢?

主要是在下一次呼叫函式時,排程器會根據這個值判斷是否應該搶佔當前 goroutine。

我們看一個 goroutine 棧如下:

func gpm() {
	print("hello runtime")
}

func main() {
	go gpm()
	time.Sleep(1 * time.Minute)
	print("hello main")
}

給 goroutine 加斷點,dlv 進入斷點處:

(dlv) b main.gpm
Breakpoint 1 set at 0x46232a for main.gpm() ./main.go:5
(dlv) c
> main.gpm() ./main.go:5 (hits goroutine(5):1 total:1) (PC: 0x46232a)
     1: package main
     2:
     3: import "time"
     4:
=>   5: func gpm() {
     6:         print("hello runtime")
     7: }
     8:
     9: func main() {
    10:         go gpm()
(dlv) disass
TEXT main.gpm(SB) /root/go/src/foundation/gpm/main.go
        main.go:5       0x462320        493b6610        cmp rsp, qword ptr [r14+0x10]
        main.go:5       0x462324        762a            jbe 0x462350
        main.go:5       0x462326        55              push rbp
        main.go:5       0x462327        4889e5          mov rbp, rsp
=>      main.go:5       0x46232a*       4883ec10        sub rsp, 0x10
        main.go:6       0x46232e        e82d28fdff      call $runtime.printlock
        ...
        main.go:5       0x462350        e8abb1ffff      call $runtime.morestack_noctxt
        main.go:5       0x462355        ebc9            jmp $main.gpm

main.gpm 棧中,首先執行 cmp rsp, qword ptr [r14+0x10] 指令,這個指令的意思是將當前棧的棧頂和 [r14+0x10] 比較,[r14+0x10] 就是 goroutine 的 stackguard0 值。如果 rsp 大於 g.stackguard0 表示棧容量是足夠的,如果小於 g.stackguard0 表示棧空間不足,需要執行 jbe 0x462350 跳轉指令,呼叫 call $runtime.morestack_noctxt 擴棧。

這裡如果 goroutine 是要被搶佔的,那麼 g.stackguard0 將被 sysmon 設定成很大的值。goroutine(中的函式) 在呼叫時,會執行 cmp rsp, qword ptr [r14+0x10] 指令比較棧頂指標和 g.stackguard0。因為棧頂 rsp 肯定小於 g.stackguard0,呼叫 call $runtime.morestack_noctxt 擴棧。

進入 runtime.morestack_noctxt

// morestack but not preserving ctxt.
TEXT runtime·morestack_noctxt(SB),NOSPLIT,$0
	MOVL	$0, DX
	JMP	runtime·morestack(SB)

TEXT runtime·morestack(SB),NOSPLIT|NOFRAME,$0-0
    ...
    // runtime.morestack 內容很多,這裡只挑重點和搶佔相關的 runtime.newstack 介紹
    BL	runtime·newstack(SB)
    ...

進入 runtime.newstack

func newstack() {
    thisg := getg()
    ...
    gp := thisg.m.curg
    ...
    stackguard0 := atomic.Loaduintptr(&gp.stackguard0)
    preempt := stackguard0 == stackPreempt                                  // 如果 gp.stackguard0 == stackPreempt,則設定搶佔標誌 preempt == true
    if preempt {
		if !canPreemptM(thisg.m) {                                          // 判斷是否可以搶佔
			// Let the goroutine keep running for now.
			// gp->preempt is set, so it will be preempted next time.
			gp.stackguard0 = gp.stack.lo + stackGuard                       // 如果不能搶佔,恢復 gp.stackguard0 為正常值
			gogo(&gp.sched) // never return                                 // gogo 執行 goroutine
		}
	}
    ...
    if preempt {                                                            // 執行到這裡,說明 goroutine 是可以搶佔的,再次判斷搶佔標誌是否為 true
		if gp == thisg.m.g0 {
			throw("runtime: preempt g0")
		}
		if thisg.m.p == 0 && thisg.m.locks == 0 {
			throw("runtime: g is running but p is not")
		}

		...

		if gp.preemptStop {                                                 // 判斷搶佔型別是否是 preemptStop,這個型別和 GC 有關,這裡我們不討論
			preemptPark(gp) // never returns
		}

		// Act like goroutine called runtime.Gosched.
		gopreempt_m(gp) // never return                                     // 重點看 gopreempt_m 進行的搶佔
	}
    ...
}

newstack 會執行搶佔邏輯,如註釋所示,經過層層執行,呼叫 gopreempt_m 搶佔執行時間過長的 goroutine:

func gopreempt_m(gp *g) {
	goschedImpl(gp)
}

func goschedImpl(gp *g) {
	status := readgstatus(gp)                           // 獲取 goroutine 的狀態
	if status&^_Gscan != _Grunning {
		dumpgstatus(gp)
		throw("bad g status")
	}
	casgstatus(gp, _Grunning, _Grunnable)               // 這時候 goroutine 還是執行的,更新 goroutine 的狀態為 _Grunnable
	dropg()                                             // 呼叫 dropg 解除執行緒和 goroutine 的繫結
	lock(&sched.lock)
	globrunqput(gp)                                     // 將 goroutine 放到全域性可執行佇列中,因為 goroutine 執行時間夠長了,不會放到 P 的本地佇列中,這也是一種懲罰機制吧
	unlock(&sched.lock)

	schedule()                                          // 執行緒再次進入排程邏輯,執行下一個 _Grunnable 的 goroutine
}

至此,我們知道對於執行時間過長的 goroutine 是怎麼搶佔的。

再次梳理下執行流程:

  1. sysmon 監控執行緒發現執行時間過長的 goroutine,將 goroutine 的 stackguard0 更新為一個比任何棧都大的 stackPreempt 值
  2. 當執行緒進行函式呼叫時,會比較棧頂 rsp 和 g.stackguard0 檢查 goroutine 棧的棧空間。
  3. 因為更新了 goroutine 棧的 stackguard0,執行緒會走到擴充套件邏輯,進入根據 preempt 標誌位,執行對應的搶佔排程。

2. 小結

本講介紹了 sysmon 執行緒,順著 sysmon 執行緒介紹了搶佔執行時間過長的 goroutine 的實現方式。下一講會繼續介紹 sysmon 執行緒和搶佔系統呼叫時間過長的 goroutine。


相關文章