原創文章,歡迎轉載,轉載請註明出處,謝謝。
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 AX
將 runtime.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
的引數 fn
,gp
和 pc
。
首先 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 狀態就緒可以執行了。
我們根據上述分析畫出記憶體分佈如下圖:
2. 小結
到這裡建立 main goroutine 的邏輯基本介紹完了。下一講,將繼續介紹 main gouroutine 是怎麼執行起來的。