千难万险 —— goroutine 从生到死(六)
上一講說到調度器將 main goroutine 推上舞臺,為它鋪好了道路,開始執行 runtime.main 函數。這一講,我們探索 main goroutine 以及普通 goroutine 從執行到退出的整個過程。
// The main goroutine. func main() { // g = main goroutine,不再是 g0 了 g := getg() // …………………… if sys.PtrSize == 8 { maxstacksize = 1000000000 } else { maxstacksize = 250000000 } // Allow newproc to start new Ms. mainStarted = true systemstack(func() { // 創建監控線程,該線程獨立于調度器,不需要跟 p 關聯即可運行 newm(sysmon, nil) }) lockOSThread() if g.m != &m0 { throw("runtime.main not on m0") } // 調用 runtime 包的初始化函數,由編譯器實現 runtime_init() // must be before defer if nanotime() == 0 { throw("nanotime returning zero") } // Defer unlock so that runtime.Goexit during init does the unlock too. needUnlock := true defer func() { if needUnlock { unlockOSThread() } }() // Record when the world started. Must be after runtime_init // because nanotime on some platforms depends on startNano. runtimeInitTime = nanotime() // 開啟垃圾回收器 gcenable() main_init_done = make(chan bool) // …………………… // main 包的初始化,遞歸的調用我們 import 進來的包的初始化函數 fn := main_init fn() close(main_init_done) needUnlock = false unlockOSThread() // …………………… // 調用 main.main 函數 fn = main_main fn() if raceenabled { racefini() } // …………………… // 進入系統調用,退出進程,可以看出 main goroutine 并未返回,而是直接進入系統調用退出進程了 exit(0) // 保護性代碼,如果 exit 意外返回,下面的代碼會讓該進程 crash 死掉 for { var x *int32 *x = 0 } }main 函數執行流程如下圖:
從流程圖可知,main goroutine 執行完之后就直接調用 exit(0) 退出了,這會導致整個進程退出,太粗暴了。
不過,main goroutine 實際上就是代表用戶的 main 函數,它都執行完了,肯定是用戶的任務都執行完了,直接退出就可以了,就算有其他的 goroutine 沒執行完,同樣會直接退出。
package main import "fmt" func main() { go func() {fmt.Println("hello qcrao.com")}() }在這個例子中,main gorutine 退出時,還來不及執行 go出去 的函數,整個進程就直接退出了,打印語句不會執行。因此,main goroutine 不會等待其他 goroutine 執行完再退出,知道這個有時能解釋一些現象,比如上面那個例子。
這時,心中可能會跳出疑問,我們在新創建 goroutine 的時候,不是整出了個“偷天換日”,風風火火地設置了 goroutine 退出時應該跳到 runtime.goexit 函數嗎,怎么這會不用了,閑得慌?
回顧一下上一講的內容,跳轉到 main 函數的兩行代碼:
// 把 sched.pc 值放入 BX 寄存器 MOVQ gobuf_pc(BX), BX // JMP 把 BX 寄存器的包含的地址值放入 CPU 的 IP 寄存器,于是,CPU 跳轉到該地址繼續執行指令 JMP BX直接使用了一個跳轉,并沒有使用 CALL 指令,而 runtime.main 函數中確實也沒有 RET 返回的指令。所以,main goroutine 執行完后,直接調用 exit(0) 退出整個進程。
那之前整地“偷天換日”還有用嗎?有的!這是針對非 main goroutine 起作用。
參考資料【阿波張 非 goroutine 的退出】中用調試工具驗證了非 main goroutine 的退出,感興趣的可以去跟著實踐一遍。
我們繼續探索非 main goroutine (后文我們就稱 gp 好了)的退出流程。
gp 執行完后,RET 指令彈出 goexit 函數地址(實際上是 funcPC(goexit)+1),CPU 跳轉到 goexit 的第二條指令繼續執行:
// src/runtime/asm_amd64.s // The top-most function running on a goroutine // returns to goexit+PCQuantum. TEXT runtime·goexit(SB),NOSPLIT,$0-0 BYTE $0x90 // NOP CALL runtime·goexit1(SB) // does not return // traceback from goexit1 must hit code range of goexit BYTE $0x90 // NOP直接調用 runtime·goexit1:
// src/runtime/proc.go // Finishes execution of the current goroutine. func goexit1() { // …………………… mcall(goexit0) }調用 mcall 函數:
// 切換到 g0 棧,執行 fn(g) // Fn 不能返回 TEXT runtime·mcall(SB), NOSPLIT, $0-8 // 取出參數的值放入 DI 寄存器,它是 funcval 對象的指針,此場景中 fn.fn 是 goexit0 的地址 MOVQ fn+0(FP), DI get_tls(CX) // AX = g MOVQ g(CX), AX // save state in g->sched // mcall 返回地址放入 BX MOVQ 0(SP), BX // caller's PC // g.sched.pc = BX,保存 g 的 PC MOVQ BX, (g_sched+gobuf_pc)(AX) LEAQ fn+0(FP), BX // caller's SP // 保存 g 的 SP MOVQ BX, (g_sched+gobuf_sp)(AX) MOVQ AX, (g_sched+gobuf_g)(AX) MOVQ BP, (g_sched+gobuf_bp)(AX) // switch to m->g0 & its stack, call fn MOVQ g(CX), BX MOVQ g_m(BX), BX // SI = g0 MOVQ m_g0(BX), SI CMPQ SI, AX // if g == m->g0 call badmcall JNE 3(PC) MOVQ $runtime·badmcall(SB), AX JMP AX // 把 g0 的地址設置到線程本地存儲中 MOVQ SI, g(CX) // g = m->g0 // 從 g 的棧切換到了 g0 的棧D MOVQ (g_sched+gobuf_sp)(SI), SP // sp = m->g0->sched.sp // AX = g,參數入棧 PUSHQ AX MOVQ DI, DX // DI 是結構體 funcval 實例對象的指針,它的第一個成員才是 goexit0 的地址 // 讀取第一個成員到 DI 寄存器 MOVQ 0(DI), DI // 調用 goexit0(g) CALL DI POPQ AX MOVQ $runtime·badmcall2(SB), AX JMP AX RET函數參數是:
type funcval struct { fn uintptr // variable-size, fn-specific data here }字段 fn 就表示 goexit0 函數的地址。
L5 將函數參數保存到 DI 寄存器,這里 fn.fn 就是 goexit0 的地址。
L7 將 tls 保存到 CX 寄存器,L9 將 當前線程指向的 goroutine (非 main goroutine,稱為 gp)保存到 AX 寄存器,L11 將調用者(調用 mcall 函數)的棧頂,這里就是 mcall 完成后的返回地址,存入 BX 寄存器。
L13 將 mcall 的返回地址保存到 gp 的 g.sched.pc 字段,L14 將 gp 的棧頂,也就是 SP 保存到 BX 寄存器,L16 將 SP 保存到 gp 的 g.sched.sp 字段,L17 將 g 保存到 gp 的 g.sched.g 字段,L18 將 BP 保存 到 gp 的 g.sched.bp 字段。這一段主要是保存 gp 的調度信息。
L21 將當前指向的 g 保存到 BX 寄存器,L22 將 g.m 字段保存到 BX 寄存器,L23 將 g.m.g0 字段保存到 SI,g.m.g0 就是當前工作線程的 g0。
現在,SI = g0, AX = gp,L25 判斷 gp 是否是 g0,如果 gp == g0 說明有問題,執行 runtime·badmcall。正常情況下,PC 值加 3,跳過下面的兩條指令,直接到達 L30。
L30 將 g0 的地址設置到線程本地存儲中,L32 將 g0.SP 設置到 CPU 的 SP 寄存器,這也就意味著我們從 gp 棧切換到了 g0 的棧,要變天了!
L34 將參數 gp 入棧,為調用 goexit0 構造參數。L35 將 DI 寄存器的內容設置到 DX 寄存器,DI 是結構體 funcval 實例對象的指針,它的第一個成員才是 goexit0 的地址。L36 讀取 DI 第一成員,也就是 goexit0 函數的地址。
L40 調用 goexit0 函數,這已經是在 g0 棧上執行了,函數參數就是 gp。
到這里,就會去執行 goexit0 函數,注意,這里永遠都不會返回。所以,在 CALL 指令后面,如果返回了,又會去調用 runtime.badmcall2 函數去處理意外情況。
來繼續看 goexit0:
// goexit continuation on g0. // 在 g0 上執行 func goexit0(gp *g) { // g0 _g_ := getg() casgstatus(gp, _Grunning, _Gdead) if isSystemGoroutine(gp) { atomic.Xadd(&sched.ngsys, -1) } // 清空 gp 的一些字段 gp.m = nil gp.lockedm = nil _g_.m.lockedg = nil gp.paniconfault = false gp._defer = nil // should be true already but just in case. gp._panic = nil // non-nil for Goexit during panic. points at stack-allocated data. gp.writebuf = nil gp.waitreason = "" gp.param = nil gp.labels = nil gp.timer = nil // Note that gp's stack scan is now "valid" because it has no // stack. gp.gcscanvalid = true // 解除 g 與 m 的關系 dropg() if _g_.m.locked&^_LockExternal != 0 { print("invalid m->locked = ", _g_.m.locked, "\n") throw("internal lockOSThread error") } _g_.m.locked = 0 // 將 g 放入 free 隊列緩存起來 gfput(_g_.m.p.ptr(), gp) schedule() }它主要完成最后的清理工作:
把 g 的狀態從 _Grunning 更新為 _Gdead;
清空 g 的一些字段;
調用 dropg 函數解除 g 和 m 之間的關系,其實就是設置 g->m = nil, m->currg = nil;
把 g 放入 p 的 freeg 隊列緩存起來供下次創建 g 時快速獲取而不用從內存分配。freeg 就是 g 的一個對象池;
調用 schedule 函數再次進行調度。
到這里,gp 就完成了它的歷史使命,功成身退,進入了 goroutine 緩存池,待下次有任務再重新啟用。
而工作線程,又繼續調用 schedule 函數進行新一輪的調度,整個過程形成了一個循環。
總結一下,main goroutine 和普通 goroutine 的退出過程:
對于 main goroutine,在執行完用戶定義的 main 函數的所有代碼后,直接調用 exit(0) 退出整個進程,非常霸道。
對于普通 goroutine 則沒這么“舒服”,需要經歷一系列的過程。先是跳轉到提前設置好的 goexit 函數的第二條指令,然后調用 runtime.goexit1,接著調用 mcall(goexit0),而 mcall 函數會切換到 g0 棧,運行 goexit0 函數,清理 goroutine 的一些字段,并將其添加到 goroutine 緩存池里,然后進入 schedule 調度循環。到這里,普通 goroutine 才算完成使命。
參考資料
【阿波張 非 main goroutine 的退出及調度循環】https://mp.weixin.qq.com/s/XttP9q7-PO7VXhskaBzGqA總結
以上是生活随笔為你收集整理的千难万险 —— goroutine 从生到死(六)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 生生世世 —— schedule 的轮回
- 下一篇: 意犹未尽 —— GPM 的状态流转(十)