Go runtime 排程器精講(三):main goroutine 建立

胡云Troy發表於2024-09-13

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


0. 前言

回顧下 上一講 的內容。主執行緒 m0 蓄勢待發,準備幹活。g0 為 m0 提供了執行環境,P 和 m0 繫結,為 m0 提供活,也就是 goroutine。那麼問題來了,活呢?哪裡有活給 m0 幹?

這一講我們將介紹 m0 執行的第一個活,也就是 main goroutine。main gouroutine 就是執行 main 函式的 goroutine,有別於用 go 關鍵字建立的 goroutine,它們在執行過程中有一些區別(後續會講)。

1. main goroutine 建立

接著上一講的內容,排程器初始化之後,執行到 asm_amd64.s/rt0_go:352

TEXT runtime·rt0_go(SB),NOSPLIT|NOFRAME|TOPFRAME,$0
		...
	    // create a new goroutine to start program
352 	MOVQ	$runtime·mainPC(SB), AX		// entry
353 	PUSHQ	AX
354 	CALL	runtime·newproc(SB)
355 	POPQ	AX

// dlv 進入到指令執行處
dlv exec ./hello
Type 'help' for list of commands.
(dlv) b /usr/local/go/src/runtime/asm_amd64.s:352
Breakpoint 1 set at 0x45433c for runtime.rt0_go() /usr/local/go/src/runtime/asm_amd64.s:352
(dlv) c
(dlv) si
> runtime.rt0_go() /usr/local/go/src/runtime/asm_amd64.s:353 (PC: 0x454343)
Warning: debugging optimized function
		asm_amd64.s:349 0x454337        e8e4290000      call $runtime.schedinit
        asm_amd64.s:352 0x45433c*       488d05659d0200  lea rax, ptr [rip+0x29d65]
=>      asm_amd64.s:353 0x454343        50              push rax

結合 CPU 執行指令和 Go plan9 彙編程式碼一起分析。

首先,將 $runtime·mainPC(SB) 地址傳給 AX 暫存器,CPU 執行的指令是 mov qword ptr [rsp+0x8], rax。使用 regs 可以看到 rax 的值,也就是 $runtime·mainPC(SB) 的地址:

(dlv) regs
    Rip = 0x0000000000454343
    Rsp = 0x00007ffd58324080
    Rax = 0x000000000047e0a8        // rax = $runtime.mainPC(SB) = [rsp+0x8]

那麼 $runtime.mainPC(SB) 的地址指的是什麼呢?我們看 $runtime.mainPC(SB) 的定義:

// mainPC is a function value for runtime.main, to be passed to newproc.
// The reference to runtime.main is made via ABIInternal, since the
// actual function (not the ABI0 wrapper) is needed by newproc.
DATA	runtime·mainPC+0(SB)/8,$runtime·main<ABIInternal>(SB)
GLOBL	runtime·mainPC(SB),RODATA,$8

$runtime.mainPC(SB) 是一個為了執行 runtime.main 的函式值。

繼續執行 PUSH AXruntime.mainPC(SB) 放到棧上。注意,這裡的棧是 g0 棧,也就是主執行緒 m0 執行的棧。

接著往下走:

=>      asm_amd64.s:354 0x45ca64        e8f72a0000              call $runtime.newproc
        asm_amd64.s:355 0x45ca69        58                      pop rax

呼叫 $runtime.newproc 函式,newproc 就是建立 goroutine 的函式。我們使用 go 關鍵字建立的 goroutine 都經編譯器轉換最終呼叫到 newproc 建立 goroutine。可想而知,這個函式是非常重要的。

進入這個函式我們的操作還是在 g0 棧。

// Create a new g running fn.
// Put it on the queue of g's waiting to run.
// The compiler turns a go statement into a call to this.
func newproc(fn *funcval) {
	gp := getg()                            // gp = g0
	pc := getcallerpc()                     // 獲取呼叫者的指令地址,也就是呼叫 newproc 時由 call 指令壓棧的函式返回地址
	systemstack(func() {
		newg := newproc1(fn, gp, pc)        // 建立 g
		...
	})
}

newproc 呼叫 newproc1 建立 goroutine,分別介紹傳入 newproc1 的引數 fngppc

首先 fn 是包含 runtime.main 的函式值,列印 fn 如下:

(dlv) print fn
(*runtime.funcval)(0x47e0a8)
*runtime.funcval {fn: 4386432}

可以看到,fn 是一個指向結構體 funcval 的地址(也就是前面介紹的 $runtime.mainPC(SB),地址 0x47e0a8),該結構體內裝的 fn 才是實際執行的 runtime.main 函式的地址:

type funcval struct {
	fn uintptr
	// variable-size, fn-specific data here
}

第二個引數 gp 等於 g0,g0 為主執行緒 m0 提供執行時環境,pc 是呼叫 newproc 時由 call 指令壓棧的函式返回地址。

引數講完了,在看下 systemstack 函式。systemstack 會將 goroutine 執行的 fn 呼叫到系統棧(g0 棧)執行,這裡 m0 已經在 g0 棧上執行了,不用呼叫。如果不是 g0 棧的 goroutine,比如 m0 執行 g1 棧,則 systemstack 會先將 g1 棧切到 g0 棧,接著執行完 fn 在返回到 g1 棧。詳細內容可以參考 這裡

現在進入 newproc1(fn, gp, pc) 檢視 newproc1 是如何建立新 goroutine 的。

func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {
	mp := acquirem()						// acquirem 獲取當前 goroutine 繫結的執行緒,這裡是 m0
	pp := mp.p.ptr()						// 獲取該執行緒繫結的 P,這裡 pp = allp[0]

	// 從 P 的本地佇列 gFree 或者全域性 gFree 佇列中獲取空閒的 goroutine,如果拿不到則返回 nil
	// 這裡是建立 main goroutine 階段,無空閒的 goroutine
	newg := gfget(pp)		
	if newg == nil {
		newg = malg(stackMin)				// malg 建立新的 goroutine
		casgstatus(newg, _Gidle, _Gdead)	// 建立的 goroutine 初始狀態是 _Gidle,這裡更新 goroutine 狀態為 _Gdead
		allgadd(newg) 						// 增加新 goroutine 到全域性變數 allgs
	}
	...
}

首先呼叫 gfget 獲取當前執行緒 P 或全域性空閒佇列中空閒的 goroutine,如果沒有則呼叫 malg(stackMin) 建立新 goroutine。malg(stackMin) 中的 stackMin 等於 2048,也就是 2K。檢視 malg 做了什麼:

func malg(stacksize int32) *g {
	newg := new(g)													// new 建立 g
	if stacksize >= 0 {												// stacksize = 2048
		stacksize = round2(stackSystem + stacksize)					// stackSystem = 0, stacksize = 2048
		systemstack(func() {
			newg.stack = stackalloc(uint32(stacksize))				// 呼叫 stackalloc 獲得新 goroutine 的棧,新 goroutine 的棧大小為 2K
		})
		newg.stackguard0 = newg.stack.lo + stackGuard
		newg.stackguard1 = ^uintptr(0)
		// Clear the bottom word of the stack. We record g
		// there on gsignal stack during VDSO on ARM and ARM64.
		*(*uintptr)(unsafe.Pointer(newg.stack.lo)) = 0
	}
	return newg
}

malg 建立一個新的 goroutine,並且 goroutine 的棧大小為 2KB。

接著呼叫 casgstatus 更新 goroutine 的狀態為 _Gdead。然後呼叫 allgadd 函式將建立的 goroutine 和全域性變數 allgs 關聯:

func allgadd(gp *g) {
	lock(&allglock)																// allgs 是全域性變數,給全域性變數加鎖
	allgs = append(allgs, gp)													// 將 newg:gp 新增到 allgs
	if &allgs[0] != allgptr {													// allgptr 是一個指向 allgs[0] 的指標,這裡是 nil
		atomicstorep(unsafe.Pointer(&allgptr), unsafe.Pointer(&allgs[0]))		// allgptr = &allgs[0]
	}
	atomic.Storeuintptr(&allglen, uintptr(len(allgs)))							// 更新全域性變數 allglen = len(allgs)
	unlock(&allglock)															// 解鎖
}

繼續往下看 newproc1 的執行過程:

func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {
	...
	totalSize := uintptr(4*goarch.PtrSize + sys.MinFrameSize) 	// extra space in case of reads slightly beyond frame
	totalSize = alignUp(totalSize, sys.StackAlign)
	sp := newg.stack.hi - totalSize								// sp 是棧頂指標

	// 設定 newg.sched 的所有成員為 0,後續要對它們重新賦值
	memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
	newg.sched.sp = sp											
	newg.stktopsp = sp

	// newg.sched.pc 表示當 newg 被排程起來執行時從這個地址開始執行指令
	newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function
	newg.sched.g = guintptr(unsafe.Pointer(newg))
	gostartcallfn(&newg.sched, fn)

這段程式碼主要是給 newg.sched 賦值,newg.sched 的結構體如下:

type gobuf struct {
	sp   uintptr				// goroutine 的 棧頂指標
	pc   uintptr				// 執行 goroutine 的指令地址
	g    guintptr				// goroutine 地址
	ctxt unsafe.Pointer			// 包裝 goroutine 執行函式的結構體 funcval 的地址
	ret  uintptr				// 返回地址
	lr   uintptr
	bp   uintptr
}

newg.sched 主要的成員如註釋所示,執行緒透過該結構體就能知道要從哪裡執行程式碼。

在賦值 newg.sched 時,這段程式碼很有意思:

newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum

它是將 goexit 函式的地址 + 1 在傳給 newg.sched.pc,檢視此時 newg.sched.pc 的值:

  4530:         newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function
=>4531:         newg.sched.g = guintptr(unsafe.Pointer(newg))
(dlv) print newg.sched
runtime.gobuf {sp: 824633976800, pc: 4540513, g: 0, ctxt: unsafe.Pointer(0x0), ret: 0, lr: 0, bp: 0}
(dlv) print unsafe.Pointer(4540513)
unsafe.Pointer(0x454861)

實際是將 0x454861 傳給了 newg.sched.pc,我們先不管這個 0x454861,接著往下看。呼叫 gostartcallfn(&newg.sched, fn) 函式:

func gostartcallfn(gobuf *gobuf, fv *funcval) {
	var fn unsafe.Pointer
	if fv != nil {
		fn = unsafe.Pointer(fv.fn)								// 將 funcval.fn 賦給 fn,實際是 runtime.main 的地址值
	} else {
		fn = unsafe.Pointer(abi.FuncPCABIInternal(nilfunc))
	}
	gostartcall(gobuf, fn, unsafe.Pointer(fv))
}

func gostartcall(buf *gobuf, fn, ctxt unsafe.Pointer) {
	sp := buf.sp												// 取 g1 的棧頂指標
	sp -= goarch.PtrSize										// 棧頂指標向下減 1 個位元組
	*(*uintptr)(unsafe.Pointer(sp)) = buf.pc					// 減的 1 個位元組空間用來放 abi.FuncPCABI0(goexit) + sys.PCQuantum
	buf.sp = sp													// 將減了 1 個位元組的 sp 作為新棧頂
	buf.pc = uintptr(fn)										// 重新將 pc 指向 fn
	buf.ctxt = ctxt												// 將 buf.ctxt 指向 funcval
}

看到這裡我們明白了,為什麼要加一層 goexit 並且將棧頂指標往下減 1 作為新棧頂了。因為新棧頂在返回時會執行到 goexit,這也是排程器希望每個 goroutine 都要做的,在執行完執行 goexit 才能真正退出。

好了我們回到 newproc1 繼續往下看:

func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {
	...
	newg.parentGoid = callergp.goid					// newg 的 父 id,newg.parentGoid = 0
	newg.gopc = callerpc							// 呼叫者的 pc
	
	newg.startpc = fn.fn							// newg.startpc = funcval.fn = &runtime.main

	...
	casgstatus(newg, _Gdead, _Grunnable)			// 更新 newg 的狀態為 _Grunnable
	newg.goid = pp.goidcache						// 透過 goidcache 獲得新的 newg.goid,這裡 main goroutine 的 goid 是 1

	...
	releasem(mp)
	return newg
}

至此我們的新的 goroutine 就建立出來了。回顧下,首先給新 goroutine 申請 2KB 的棧空間,接著在新 goroutine 中建立執行 goroutine 的環境 newg.sched,執行緒根據 newg.sched 就可以執行 goroutine。最後,設定 goroutine 的狀態為 _Grunnable,表示 goroutine 狀態就緒可以執行了。

我們根據上述分析畫出記憶體分佈如下圖:

image

2. 小結

到這裡建立 main goroutine 的邏輯基本介紹完了。下一講,將繼續介紹 main gouroutine 是怎麼執行起來的。


相關文章