本文是《Go語言調度器源代碼情景分析》系列的第15篇,也是第二章的第5小節。
上一節我們說過main goroutine退出時會直接執行exit系統調用退出整個進程,而非main goroutine退出時則會進入goexit函數完成最后的清理工作,本小節我們首先就來驗證一下非main goroutine執行完成后是否真的會去執行goexit,然后再對非main goroutine的退出流程做個梳理。這一節我們需要重點理解以下內容:
-
非main goroutine是如何返回到goexit函數的;
-
mcall函數如何從用戶goroutine切換到g0繼續執行;
-
調度循環。
非main goroutine會返回到goexit嗎
首先來看一段代碼:
package main import ( "fmt" ) func g2(n int, ch chan int) { ch <- n*n } func main() { ch := make(chan int) go g2(100, ch) fmt.Println(<-ch) }
這個程序比較簡單,main goroutine啟動后在main函數中創建了一個goroutine執行g2函數,我們稱它為g2 goroutine,下面我們就用這個g2的退出來驗證一下非main goroutine退出時是否真的會返回到goexit繼續執行。
怎么驗證呢?比較簡單的辦法就是用gdb來調試,在gdb中首先使用backtrace命令查看g2函數是被誰調用的,然后單步執行看它能否返回到goexit繼續執行。下面是gdb調試過程:
(gdb) b main.g2 // 在main.g2函數入口處下斷點 Breakpoint1at0x4869c0:file/home/bobo/study/go/goexit.go, line 7. (gdb) r Startingprogram:/home/bobo/study/go/goexit Thread1"goexit"hit Breakpoint 1 at /home/bobo/study/go/goexit.go:7 (gdb) bt //查看函數調用鏈,看起來g2真的是被runtime.goexit調用的 #0 main.g2 (n=100, ch=0xc000052060) at /home/bobo/study/go/goexit.go:7 #1 0x0000000000450ad1 in runtime.goexit () at /usr/local/go/src/runtime/asm_amd64.s:1337 (gdb) disass //反匯編找ret的地址,這是為了在ret處下斷點 Dumpofassemblercodeforfunctionmain.g2: => 0x00000000004869c0 <+0>:mov %fs:0xfffffffffffffff8,%rcx 0x00000000004869c9<+9>:cmp 0x10(%rcx),%rsp 0x00000000004869cd<+13>:jbe 0x486a0d <main.g2+77> 0x00000000004869cf<+15>:sub $0x20,%rsp 0x00000000004869d3<+19>:mov %rbp,0x18(%rsp) 0x00000000004869d8<+24>:lea 0x18(%rsp),%rbp 0x00000000004869dd<+29>:mov 0x28(%rsp),%rax 0x00000000004869e2<+34>:imul %rax,%rax 0x00000000004869e6<+38>:mov %rax,0x10(%rsp) 0x00000000004869eb<+43>:mov 0x30(%rsp),%rax 0x00000000004869f0<+48>:mov %rax,(%rsp) 0x00000000004869f4<+52>:lea 0x10(%rsp),%rax 0x00000000004869f9<+57>:mov %rax,0x8(%rsp) 0x00000000004869fe<+62>:callq 0x4046a0 <runtime.chansend1> 0x0000000000486a03<+67>:mov 0x18(%rsp),%rbp 0x0000000000486a08<+72>:add $0x20,%rsp 0x0000000000486a0c<+76>:retq 0x0000000000486a0d<+77>:callq 0x44ece0 <runtime.morestack_noctxt> 0x0000000000486a12<+82>:jmp 0x4869c0 <main.g2> Endofassemblerdump. (gdb) b *0x0000000000486a0c //在retq指令位置下斷點 Breakpoint2at0x486a0c:file/home/bobo/study/go/goexit.go, line 9. (gdb) c Continuing. Thread1"goexit"hit Breakpoint 2 at /home/bobo/study/go/goexit.go:9 (gdb) disass //程序停在了ret指令處 Dumpofassemblercodeforfunctionmain.g2: 0x00000000004869c0<+0>:mov %fs:0xfffffffffffffff8,%rcx 0x00000000004869c9<+9>:cmp 0x10(%rcx),%rsp 0x00000000004869cd<+13>:jbe 0x486a0d <main.g2+77> 0x00000000004869cf<+15>:sub $0x20,%rsp 0x00000000004869d3<+19>:mov %rbp,0x18(%rsp) 0x00000000004869d8<+24>:lea 0x18(%rsp),%rbp 0x00000000004869dd<+29>:mov 0x28(%rsp),%rax 0x00000000004869e2<+34>:imul %rax,%rax 0x00000000004869e6<+38>:mov %rax,0x10(%rsp) 0x00000000004869eb<+43>:mov 0x30(%rsp),%rax 0x00000000004869f0<+48>:mov %rax,(%rsp) 0x00000000004869f4<+52>:lea 0x10(%rsp),%rax 0x00000000004869f9<+57>:mov %rax,0x8(%rsp) 0x00000000004869fe<+62>:callq 0x4046a0 <runtime.chansend1> 0x0000000000486a03<+67>:mov 0x18(%rsp),%rbp 0x0000000000486a08<+72>:add $0x20,%rsp => 0x0000000000486a0c <+76>:retq 0x0000000000486a0d<+77>:callq 0x44ece0 <runtime.morestack_noctxt> 0x0000000000486a12<+82>:jmp 0x4869c0 <main.g2> Endofassemblerdump. (gdb) si //單步執行一條指令 runtime.goexit () at /usr/local/go/src/runtime/asm_amd64.s:1338 1338CALLruntime·goexit1(SB)// does not return (gdb) disass //可以看出來g2已經返回到了goexit函數中 Dumpofassemblercodeforfunctionruntime.goexit: 0x0000000000450ad0<+0>:nop => 0x0000000000450ad1 <+1>:callq 0x42faf0 <runtime.goexit1> 0x0000000000450ad6<+6>:nop
使用gdb調試時,首先我們在g2函數入口處下了一個斷點,程序暫停后通過查看函數調用棧發現g2函數確實是被goexit調用的,然后再一次使用斷點讓程序暫停在g2返回之前的最后一條指令retq處,最后單步執行這條指令,可以看到程序從g2函數返回到了goexit函數的第二條指令的位置,這個位置正是當初在創建goroutine時設置好的返回地址。可以看到,雖然g2函數並不是被goexit函數直接調用的,但它執行完成之后卻返回到了goexit函數中!
至此,我們已經證實非main goroutine退出時確實會返回到goexit函數繼續執行,下面我們就沿着這條線繼續分析非main goroutine的退出流程。
非main goroutine的退出流程
首先來看goexit函數
runtime/asm_amd64.s : 1334
// 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
從前面的分析我們已經看到,非main goroutine返回時直接返回到了goexit的第二條指令:CALL runtime·goexit1(SB),該指令繼續調用goexit1函數。
runtime/proc.go : 2652
// Finishes execution of the current goroutine. func goexit1() { if raceenabled { //與競態檢查有關,不關注 racegoend() } if trace.enabled { //與backtrace有關,不關注 traceGoEnd() } mcall(goexit0) }
goexit1函數通過調用mcall從當前運行的g2 goroutine切換到g0,然后在g0棧上調用和執行goexit0這個函數。
runtime/asm_amd64.s : 270
# func mcall(fn func(*g)) # Switch to m->g0's stack, call fn(g). # Fn must never return. It should gogo(&g->sched) # to keep running g. # mcall的參數是一個指向funcval對象的指針 TEXT runtime·mcall(SB), NOSPLIT, $0-8 #取出參數的值放入DI寄存器,它是funcval對象的指針,此場景中fn.fn是goexit0的地址 MOVQ fn+0(FP), DI get_tls(CX) MOVQ g(CX), AX# AX = g,本場景g 是 g2 #mcall返回地址放入BX MOVQ 0(SP), BX# caller's PC #保存g2的調度信息,因為我們要從當前正在運行的g2切換到g0 MOVQ BX, (g_sched+gobuf_pc)(AX) #g.sched.pc = BX,保存g2的rip LEAQ fn+0(FP), BX# caller's SP MOVQ BX, (g_sched+gobuf_sp)(AX) #g.sched.sp = BX,保存g2的rsp MOVQ AX, (g_sched+gobuf_g)(AX) #g.sched.g = g MOVQ BP, (g_sched+gobuf_bp)(AX) #g.sched.bp = BP,保存g2的rbp # switch to m->g0 & its stack, call fn #下面三條指令主要目的是找到g0的指針 MOVQ g(CX), BX #BX = g MOVQ g_m(BX), BX #BX = g.m MOVQ m_g0(BX), SI #SI = g.m.g0 #此刻,SI = g0, AX = g,所以這里在判斷g 是否是 g0,如果g == g0則一定是哪里代碼寫錯了 CMPQ SI, AX# if g == m->g0 call badmcall JNE 3(PC) MOVQ $runtime·badmcall(SB), AX JMP AX #把g0的地址設置到線程本地存儲之中 MOVQ SI, g(CX) #恢復g0的棧頂指針到CPU的rsp積存,這一條指令完成了棧的切換,從g的棧切換到了g0的棧 MOVQ (g_sched+gobuf_sp)(SI), SP# rsp = g0->sched.sp #AX = g PUSHQ AX #fn的參數g入棧 MOVQ DI, DX #DI是結構體funcval實例對象的指針,它的第一個成員才是goexit0的地址 MOVQ 0(DI), DI #讀取第一個成員到DI寄存器 CALL DI #調用goexit0(g) POPQ AX MOVQ $runtime·badmcall2(SB), AX JMP AX RET
mcall的參數是一個函數,在Go語言的實現中,函數變量並不是一個直接指向函數代碼的指針,而是一個指向funcval結構體對象的指針,funcval結構體對象的第一個成員fn才是真正指向函數代碼的指針。
type funcval struct { fn uintptr // variable-size, fn-specific data here }
也就是說,在我們這個場景中mcall函數的fn參數的fn成員中存放的才是goexit0函數的第一條指令的地址。
mcall函數主要有兩個功能:
-
首先從當前運行的g(我們這個場景是g2)切換到g0,這一步包括保存當前g的調度信息,把g0設置到tls中,修改CPU的rsp寄存器使其指向g0的棧;
-
以當前運行的g(我們這個場景是g2)為參數調用fn函數(此處為goexit0)。
從mcall的功能我們可以看出,mcall做的事情跟gogo函數完全相反,gogo函數實現了從g0切換到某個goroutine去運行,而mcall實現了從某個goroutine切換到g0來運行,因此,mcall和gogo的代碼非常相似,然而mcall和gogo在做切換時有個重要的區別:gogo函數在從g0切換到其它goroutine時首先切換了棧,然后通過跳轉指令從runtime代碼切換到了用戶goroutine的代碼,而mcall函數在從其它goroutine切換回g0時只切換了棧,並未使用跳轉指令跳轉到runtime代碼去執行。為什么會有這個差別呢?原因在於在從g0切換到其它goroutine之前執行的是runtime的代碼而且使用的是g0棧,所以切換時需要首先切換棧然后再從runtime代碼跳轉某個goroutine的代碼去執行(切換棧和跳轉指令不能顛倒,因為跳轉之后執行的就是用戶的goroutine代碼了,沒有機會切換棧了),然而從某個goroutine切換回g0時,goroutine使用的是call指令來調用mcall函數,mcall函數本身就是runtime的代碼,所以call指令其實已經完成了從goroutine代碼到runtime代碼的跳轉,因此mcall函數自身的代碼就不需要再跳轉了,只需要把棧切換到g0棧即可。
因為mcall跟gogo非常相似,前面我們對gogo的每一條指令已經做過詳細的分析,所以這里就不再詳細解釋mcall的每一條指令了,但筆者在上面所展示的mcall代碼中做了一些注釋(注釋中的g表示當前正在運行的goroutine,我們這個場景g就是g2),這里大家可以結合gogo的代碼以及mcall的代碼和注釋來加深對g0與其它goroutine之間的切換的理解。
從g2棧切換到g0棧之后,下面開始在g0棧執行goexit0函數,該函數完成最后的清理工作:
-
把g的狀態從_Grunning變更為_Gdead;
-
然后把g的一些字段清空成0值;
-
調用dropg函數解除g和m之間的關系,其實就是設置g->m = nil, m->currg = nil;
-
把g放入p的freeg隊列緩存起來供下次創建g時快速獲取而不用從內存分配。freeg就是g的一個對象池;
-
調用schedule函數再次進行調度;
runtime/proc.go : 2662
// goexit continuation on g0. func goexit0(gp*g) { _g_ := getg() //g0 casgstatus(gp, _Grunning, _Gdead) //g馬上退出,所以設置其狀態為_Gdead if isSystemGoroutine(gp, false) { atomic.Xadd(&sched.ngsys, -1) } //清空g保存的一些信息 gp.m=nil locked:=gp.lockedm!=0 gp.lockedm=0 _g_.m.lockedg=0 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=0 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 = nil, m->currg = nil 解綁g和m之關系 dropg() ...... gfput(_g_.m.p.ptr(), gp) //g放入p的freeg隊列,方便下次重用,免得再去申請內存,提高效率 ...... //下面再次調用schedule schedule() }
到此為止g2的生命周期就結束了,工作線程再次調用了schedule函數進入新一輪的調度循環。
調度循環
我們說過,任何goroutine被調度起來運行都是通過schedule()->execute()->gogo()這個函數調用鏈完成的,而且這個調用鏈中的函數一直沒有返回。以我們剛剛討論過的g2 goroutine為例,從g2開始被調度起來運行到退出是沿着下面這條路徑進行的
schedule()->execute()->gogo()->g2()->goexit()->goexit1()->mcall()->goexit0()->schedule()
可以看出,一輪調度是從調用schedule函數開始的,然后經過一系列代碼的執行到最后又再次通過調用schedule函數來進行新一輪的調度,從一輪調度到新一輪調度的這一過程我們稱之為一個調度循環,這里說的調度循環是指某一個工作線程的調度循環,而同一個Go程序中可能存在多個工作線程,每個工作線程都有自己的調度循環,也就是說每個工作線程都在進行着自己的調度循環。
從前面的代碼分析可以得知,上面調度循環中的每一個函數調用都沒有返回,雖然g2()->goexit()->goexit1()->mcall()這幾個函數是在g2的棧空間執行的,但剩下的函數都是在g0的棧空間執行的,那么問題就來了,在一個復雜的程序中,調度可能會進行無數次循環,也就是說會進行無數次沒有返回的函數調用,大家都知道,每調用一次函數都會消耗一定的棧空間,而如果一直這樣無返回的調用下去無論g0有多少棧空間終究是會耗盡的,那么這里是不是有問題?其實沒有問題,關鍵點就在於,每次執行mcall切換到g0棧時都是切換到g0.sched.sp所指的固定位置,這之所以行得通,正是因為從schedule函數開始之后的一系列函數永遠都不會返回,所以重用這些函數上一輪調度時所使用過的棧內存是沒有問題的。
每個工作線程的執行流程和調度循環都一樣,如下圖所示:
總結
我們用上圖來總結一下工作線程的執行流程:
-
初始化,調用mstart函數;
-
調用mstart1函數,在該函數中調用save函數設置g0.sched.sp和g0.sched.pc等調度信息,其中g0.sched.sp指向mstart函數棧幀的棧頂;
-
依次調用schedule->execute->gogo函數執行調度;
-
運行用戶的goroutine代碼;
-
用戶goroutine代碼執行過程中調用runtime中的某些函數,然后這些函數調用mcall切換到g0.sched.sp所指的棧並最終再次調用schedule函數進入新一輪調度,之后工作線程一直循環執行着3~5這一調度循環直到進程退出為止。