Go runtime 排程器精講(四):執行 main goroutine

胡云Troy發表於2024-09-13

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


0. 前言

皇天不負有心人,終於我們到了執行 main goroutine 環節了。讓我們走起來,看看一個 goroutine 到底是怎麼執行的。

1. 執行 goroutine

稍微回顧下前面的內容,第一講 Go 程式初始化,介紹了 Go 程式是怎麼進入到 runtime 的,隨之揭開 runtime 的面紗。第二講,介紹了排程器的初始化,要執行 goroutine 排程器是必不可少的,只有排程器準備就緒才能開始工作。第三講,介紹了 main goroutine 是如何建立出來的,只有建立一個 goroutine 才能開始執行,否則執行程式碼無從談起。這一講,我們繼續介紹如何執行 main goroutine。

我們知道 main goroutine 此時處於 _Grunnable 狀態,要使得 main goroutine 處於 _Grunning 狀態,還需要將它和 P 繫結。畢竟 P 是負責排程任務給執行緒處理的,只有和 P 繫結執行緒才能處理相應的 goroutine。

1.1 繫結 P

回到程式碼 newproc

func newproc(fn *funcval) {
	gp := getg()
	pc := getcallerpc()
	systemstack(func() {
		newg := newproc1(fn, gp, pc)        // 建立 newg,這裡是 main goroutine

		pp := getg().m.p.ptr()              // 獲取當前工作執行緒繫結的 P,這裡是 g0.m.p = allp[0]
		runqput(pp, newg, true)             // 繫結 allp[0] 和 main goroutine

		if mainStarted {                    // mainStarted 還未啟動,這裡是 false
			wakep()
		}
	})
}

進入 runqput 函式檢視 main goroutine 是怎麼和 allp[0] 繫結的:

// runqput tries to put g on the local runnable queue.
// If next is false, runqput adds g to the tail of the runnable queue.
// If next is true, runqput puts g in the pp.runnext slot.
// If the run queue is full, runnext puts g on the global queue.
// Executed only by the owner P.
func runqput(pp *p, gp *g, next bool) {
	...
	if next {
	retryNext:
		oldnext := pp.runnext                                               // 從 P 的 runnext 獲取下一個將要執行的 goroutine,這裡 pp.runnext = nil
		if !pp.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {         // 將 P 的 runnext 更新為 gp,這裡的 gp 是 main goroutine
			goto retryNext  
		}
		if oldnext == 0 {                                                   // 如果 P 原來要執行的 goroutine 是 nil,則直接返回,這裡建立的是 main goroutine 將直接返回
			return
		}
		gp = oldnext.ptr()                                                  // 如果不為 nil,表示是一個將要執行的 goroutine。後續對這個被趕走的 goroutine 進行處理
	}

retry:
	h := atomic.LoadAcq(&pp.runqhead)
	t := pp.runqtail
    
	if t-h < uint32(len(pp.runq)) {                                         // P 的隊尾和隊頭指向本地執行佇列 runq,如果當前佇列長度小於 runq 則將趕走的 goroutine 新增到隊尾
		pp.runq[t%uint32(len(pp.runq))].set(gp)
		atomic.StoreRel(&pp.runqtail, t+1)
		return
	}

	if runqputslow(pp, gp, h, t) {                                          // 如果當前 P 的佇列長度等於不小於 runq,表示本地佇列滿了,將趕走的 goroutine 新增到全域性佇列中
		return
	}

	goto retry
}

runqput 函式繫結 P 和 goroutine,同時處理 P 中的本地執行佇列。基本流程在註釋中已經介紹的比較清楚了。

這裡我們繫結的是 main goroutine,直接繫結到 P 的 runnext 成員即可。不過對於 runqput 的整體處理來說,還需要在介紹一下 runqputslow 函式:

// Put g and a batch of work from local runnable queue on global queue.
// Executed only by the owner P.
func runqputslow(pp *p, gp *g, h, t uint32) bool {
	var batch [len(pp.runq)/2 + 1]*g                                                // 定義 batch,長度是 P.runq 的一半。batch 用來裝 g

	// First, grab a batch from local queue.
	n := t - h
	n = n / 2
	if n != uint32(len(pp.runq)/2) {
		throw("runqputslow: queue is not full")
	}
	for i := uint32(0); i < n; i++ {
		batch[i] = pp.runq[(h+i)%uint32(len(pp.runq))].ptr()                        // 從 P 的 runq 中拿出一半的 g 到 batch 中
	}
	if !atomic.CasRel(&pp.runqhead, h, h+n) { // cas-release, commits consume       // 更新 P 的 runqhead 的指向,它指向的是本地佇列的頭
		return false
	}
	batch[n] = gp                                                                   // 將趕走的 goroutine 放到 batch 尾

	if randomizeScheduler {                                                         // 如果是隨機排程的話,這裡還要打亂 batch 中 g 的順序以保證隨機性
		for i := uint32(1); i <= n; i++ {
			j := fastrandn(i + 1)
			batch[i], batch[j] = batch[j], batch[i]
		}
	}

	// Link the goroutines.
	for i := uint32(0); i < n; i++ {
		batch[i].schedlink.set(batch[i+1])                                          // batch 中 goroutine 的 schedlink 按順序指向其它 goroutine,構造一個連結串列
	}
	var q gQueue                                                                    // gQueue 是一個包含頭和尾的指標,將頭和尾指標分別指向 batch 的頭 batch[0] 和尾 batch[n]
	q.head.set(batch[0])
	q.tail.set(batch[n])

	// Now put the batch on global queue.
	lock(&sched.lock)                                                               // 操作全域性變數 sched,為 sched 加鎖
	globrunqputbatch(&q, int32(n+1))                                                // globrunqputbatch 將 q 指向的 batch 傳給全域性變數 sched
	unlock(&sched.lock)                                                             // 解鎖
	return true
}

func globrunqputbatch(batch *gQueue, n int32) {
	assertLockHeld(&sched.lock)

	sched.runq.pushBackAll(*batch)                                                  // 這裡將 sched.runq 指向 batch
	sched.runqsize += n                                                             // sched 的 runqsize 加 n,n 表示新新增進 sched.runq 的 goroutine
	*batch = gQueue{}
}

如果 P 的本地佇列已滿,則在 runqputslow 中拿出本地佇列的一半 goroutine 放到 sched.runq 全域性佇列中。這裡本地佇列是固定長度,容量有限,用陣列來表示佇列。而全域性佇列長度是不固定的,用連結串列來表示全域性佇列。

我們可以畫出示意圖如下圖,注意示意圖只是加深理解,和我們這裡執行 main goroutine 的流程沒關係:

image

1.2 執行 main goroutine

P 和 main goroutine 繫結之後,理論上已經可以執行 main goroutine 了。繼續看程式碼執行的什麼:

> runtime.rt0_go() /usr/local/go/src/runtime/asm_amd64.s:358 (PC: 0x45434a)
Warning: debugging optimized function
   353:         PUSHQ   AX
   354:         CALL    runtime·newproc(SB)
   355:         POPQ    AX
   356:
   357:         // start this M
=> 358:         CALL    runtime·mstart(SB)      // 呼叫 mstart 意味著當前執行緒開始工作了;mstart 是一個永不返回的函式
   359:
   360:         CALL    runtime·abort(SB)       // mstart should never return
   361:         RET
   362:

向下執行:

(dlv) si
> runtime.mstart() /usr/local/go/src/runtime/asm_amd64.s:394 (PC: 0x4543c0)
Warning: debugging optimized function
TEXT runtime.mstart(SB) /usr/local/go/src/runtime/asm_amd64.s
=>      asm_amd64.s:394 0x4543c0        e87b290000      call $runtime.mstart0
        asm_amd64.s:395 0x4543c5        c3              ret

呼叫 runtime.mstart0

func mstart0() {
	gp := getg()                // gp = g0
    ...
    mstart1()
    ...
}

呼叫 mstart1

func mstart1() {
	gp := getg()                                    // gp = g0

    // 儲存執行緒執行的棧,當執行緒進入 schedule 函式就不會返回,這意味著執行緒執行的棧是可複用的
    gp.sched.g = guintptr(unsafe.Pointer(gp))
	gp.sched.pc = getcallerpc()
	gp.sched.sp = getcallersp()

    ...
    if fn := gp.m.mstartfn; fn != nil {             // 執行 main goroutine,fn == nil
		fn()
	}

    ...
    schedule()                                      // 執行緒進入 schedule 排程迴圈,該迴圈是永不返回的
}

進入 schedule

func schedule() {
	mp := getg().m                                  // mp = m0
    ...
top:
	pp := mp.p.ptr()                                // pp = allp[0]
	pp.preempt = false

    // 執行緒有兩種狀態,自旋和非自旋。自旋表示執行緒沒有工作,在找工作階段。非自旋表示執行緒正在工作
    // 這裡如果執行緒自旋,但是執行緒繫結的 P 本地佇列有 goroutine 則報異常
    if mp.spinning && (pp.runnext != 0 || pp.runqhead != pp.runqtail) {
		throw("schedule: spinning with local work")
	}

    // blocks until work is available
    gp, inheritTime, tryWakeP := findRunnable()     // 找一個處於 _Grunnable 狀態的 goroutine 出來

    ...
    execute(gp, inheritTime)                        // 執行該 goroutine,這裡執行的是 main goroutine
}

schedule 中的重點是 findRunaable 函式,進入該函式:

// Finds a runnable goroutine to execute.
// Tries to steal from other P's, get g from local or global queue, poll network.
// tryWakeP indicates that the returned goroutine is not normal (GC worker, trace
// reader) so the caller should try to wake a P.
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
	mp := getg().m                      // mp = m0

top:
	pp := mp.p.ptr()                    // pp = allp[0] = p0

    ...
    // Check the global runnable queue once in a while to ensure fairness.
    // Otherwise two goroutines can completely occupy the local runqueue
    // by constantly respawning each other.
    // 官方的註釋對這一段邏輯已經解釋的很詳細了,我們就跳過了,偷個懶
    if pp.schedtick%61 == 0 && sched.runqsize > 0 {
        lock(&sched.lock)
		gp := globrunqget(pp, 1)
		unlock(&sched.lock)
		if gp != nil {
			return gp, false, false
		}
    }

    // local runq
    // 從 P 的本地佇列找 goroutine
	if gp, inheritTime := runqget(pp); gp != nil {
		return gp, inheritTime, false
	}
    ...
}

findRunnable 中首先為了公平,每呼叫 schedule 函式 61 次就要從全域性可執行佇列中獲取 goroutine,防止全域性佇列中的 goroutine 被“餓死”。接著從 P 的本地佇列中獲取 goroutine,這裡執行的是 main goroutine 將從 P 的本地佇列中獲取 goroutine。檢視 runqget

func runqget(pp *p) (gp *g, inheritTime bool) {
	// If there's a runnext, it's the next G to run.
	next := pp.runnext
	// If the runnext is non-0 and the CAS fails, it could only have been stolen by another P,
	// because other Ps can race to set runnext to 0, but only the current P can set it to non-0.
	// Hence, there's no need to retry this CAS if it fails.
	if next != 0 && pp.runnext.cas(next, 0) {
		return next.ptr(), true
	}

	for {
		h := atomic.LoadAcq(&pp.runqhead) // load-acquire, synchronize with other consumers
		t := pp.runqtail
		if t == h {
			return nil, false
		}
		gp := pp.runq[h%uint32(len(pp.runq))].ptr()
		if atomic.CasRel(&pp.runqhead, h, h+1) { // cas-release, commits consume
			return gp, false
		}
	}
}

註釋已經比較詳細了,首先拿到 P 的 runnext 作為要執行的 goroutine。如果拿到的 goroutine 不是空,則重置 runnext,並且返回拿到的 goroutine。如果拿到的 goroutine 是空的,則從本地佇列中拿 goroutine。

透過 findRunnable 我們拿到可執行的 main goroutine。接著呼叫 execute 執行 main goroutine。

進入 execute

func execute(gp *g, inheritTime bool) {
	mp := getg().m                                  // mp = m0

    mp.curg = gp                                    // mp.curg = g1
	gp.m = mp                                       // gp.m = m0
	casgstatus(gp, _Grunnable, _Grunning)           // 更新 goroutine 的狀態為 _Grunning
	gp.waitsince = 0
	gp.preempt = false
	gp.stackguard0 = gp.stack.lo + stackGuard
	if !inheritTime {
		mp.p.ptr().schedtick++
	}

    ...
    gogo(&gp.sched)                             
}

execute 中將執行緒和 gouroutine 關聯起來,更新 goroutine 的狀態,然後呼叫 gogo 完成從 g0 棧到 gp 棧的切換,gogo 是用匯編編寫的,原因如下:

gogo 函式也是透過組合語言編寫的,這裡之所以需要使用匯編,是因為 goroutine 的排程涉及不同執行流之間的切換。

前面我們在討論作業系統切換執行緒時已經看到過,執行流的切換從本質上來說就是 CPU 暫存器以及函式呼叫棧的切換,然而不管是 go 還是 c 這種高階語言都無法精確控制 CPU 暫存器,因而高階語言在這裡也就無能為力了,只能依靠彙編指令來達成目的。

進入 gogogogo 傳入的是 goroutine 的 sched 結構:

TEXT runtime·gogo(SB), NOSPLIT, $0-8
	MOVQ	buf+0(FP), BX		                // gobuf
	MOVQ	gobuf_g(BX), DX                     // gobuf 的 g 賦給 DX
	MOVQ	0(DX), CX		                    // make sure g != nil
	JMP	gogo<>(SB)                              // 跳轉到私有函式 gogo<>

TEXT gogo<>(SB), NOSPLIT, $0
	get_tls(CX)                                 // 獲取當前執行緒 tls 中的 goroutine
	MOVQ	DX, g(CX)
	MOVQ	DX, R14		                        // set the g register
	MOVQ	gobuf_sp(BX), SP	                // restore SP
	MOVQ	gobuf_ret(BX), AX                   // AX = gobuf.ret
	MOVQ	gobuf_ctxt(BX), DX                  // DX = gobuf.ctxt
	MOVQ	gobuf_bp(BX), BP                    // BP = gobuf.bp
	MOVQ	$0, gobuf_sp(BX)	                // clear to help garbage collector
	MOVQ	$0, gobuf_ret(BX)
	MOVQ	$0, gobuf_ctxt(BX)
	MOVQ	$0, gobuf_bp(BX)
	MOVQ	gobuf_pc(BX), BX                    // BX = gobuf.pc
	JMP	BX                                      // 跳轉到 gobuf.pc 

gogo<> 中完成 g0 到 gp 棧的切換:MOVQ gobuf_sp(BX), SP,並且跳轉到 gobuf.pc 執行。我們看 gobuf.pc 要執行的指令地址是什麼:

asm_amd64.s:421 0x45363a        488b5b08                mov rbx, qword ptr [rbx+0x8]
=>      asm_amd64.s:422 0x45363e        ffe3                    jmp rbx
(dlv) regs
    Rbx = 0x000000000042ee80

執行 JMP BX 跳轉到 0x000000000042ee80

(dlv) si
> runtime.main() /usr/local/go/src/runtime/proc.go:144 (PC: 0x42ee80)
Warning: debugging optimized function
TEXT runtime.main(SB) /usr/local/go/src/runtime/proc.go
=>      proc.go:144     0x42ee80        4c8d6424e8      lea r12, ptr [rsp-0x18]

終於我們揭開了它的神秘面紗,這個指令指向的是 runtime.main 函式的第一條彙編指令。也就是說,跳轉到了 runtime.main,這個函式會呼叫我們 main 包下的 main 函式。檢視 runtime.main 函式:

// The main goroutine.
func main() {
	mp := getg().m                          // mp = m0

    if goarch.PtrSize == 8 {
		maxstacksize = 1000000000           // 擴棧,棧的最大空間是 1GB
	} else {
		maxstacksize = 250000000
	}

    ...
    // Allow newproc to start new Ms.
	mainStarted = true

    if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon
		systemstack(func() {
			newm(sysmon, nil, -1)           // 開啟監控執行緒,這個執行緒很重要,我們後續會講,這裡先放著,讓 sysmon 飛一會兒
		})
	}

    ...
    // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
    fn := main_main                         // 這裡的 main_main 連結的是 main 包中的 main 函式
	fn()                                    // 執行 main.main
    ...
    runExitHooks(0)

	exit(0)                                 // 執行完 main.main 之後呼叫 exit 退出執行緒
    for {
		var x *int32
		*x = 0
	}
}

runtime.main 是在 main goroutine 棧中執行的。在函式中呼叫 main.main 執行我們寫的使用者程式碼:

(dlv) n
266:            fn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
=> 267:         fn()
(dlv) s
> main.main() ./hello.go:3 (PC: 0x45766a)
Warning: debugging optimized function
     1: package main
     2:
=>   3: func main() {
     4:         println("Hello World")
     5: }

main.main 執行完之後執行緒呼叫 exit(0) 退出程式。

2. 小結

至此我們的 main goroutine 就執行完了,花了四講才算走通了一個 main goroutine,真不容易呀。當然,關於 Go runtime 排程器的故事還沒結束,下一講我們繼續。


相關文章