本文是《Go語言調度器源代碼情景分析》系列的第15篇,也是第二章的第5小節。緩存
上一節咱們說過main goroutine退出時會直接執行exit系統調用退出整個進程,而非main goroutine退出時則會進入goexit函數完成最後的清理工做,本小節咱們首先就來驗證一下非main goroutine執行完成後是否真的會去執行goexit,而後再對非main goroutine的退出流程作個梳理。這一節咱們須要重點理解如下內容:sass
非main goroutine是如何返回到goexit函數的;函數
mcall函數如何從用戶goroutine切換到g0繼續執行;ui
調度循環。atom
非main goroutine會返回到goexit嗎spa
首先來看一段代碼:線程
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函數中!rest
至此,咱們已經證明非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這一調度循環直到進程退出爲止。