上一講說到調度器將maingoroutine推上舞臺,為它鋪好了道路,開始執行runtime.main
函數。這一講,我們探索maingoroutine以及普通goroutine從執行到退出的整個過程。
//Themaingoroutine.
funcmain(){
//g=maingoroutine,不再是g0了
g:=getg()//……………………ifsys.PtrSize==8{
maxstacksize=1000000000
}else{
maxstacksize=250000000
}//AllownewproctostartnewMs.
mainStarted=truesystemstack(func(){
//創建監控線程,該線程獨立于調度器,不需要跟p關聯即可運行
newm(sysmon,nil)
})lockOSThread()ifg.m!=&m0{
throw("runtime.mainnotonm0")
}//調用runtime包的初始化函數,由編譯器實現
runtime_init()//mustbebeforedefer
ifnanotime()==0{
throw("nanotimereturningzero")
}//Deferunlocksothatruntime.Goexitduringinitdoestheunlocktoo.
needUnlock:=true
deferfunc(){
ifneedUnlock{
unlockOSThread()
}
}()//Recordwhentheworldstarted.Mustbeafterruntime_init
//becausenanotimeonsomeplatformsdependsonstartNano.
runtimeInitTime=nanotime()//開啟垃圾回收器
gcenable()main_init_done=make(chanbool)//……………………//main包的初始化,遞歸的調用我們import進來的包的初始化函數
fn:=main_init
fn()
close(main_init_done)needUnlock=false
unlockOSThread()//……………………//調用main.main函數
fn=main_main
fn()
ifraceenabled{
racefini()
}//……………………//進入系統調用,退出進程,可以看出maingoroutine并未返回,而是直接進入系統調用退出進程了
exit(0)
//保護性代碼,如果exit意外返回,下面的代碼會讓該進程crash死掉
for{
varx*int32
*x=0
}
}
main
函數執行流程如下圖:
從流程圖可知,maingoroutine執行完之后就直接調用exit(0)
退出了,這會導致整個進程退出,太粗暴了。
不過,maingoroutine實際上就是代表用戶的main函數,它都執行完了,肯定是用戶的任務都執行完了,直接退出就可以了,就算有其他的goroutine沒執行完,同樣會直接退出。
packagemainimport"fmt"funcmain(){
gofunc(){fmt.Println("helloqcrao.com")}()
}
在這個例子中,maingorutine退出時,還來不及執行go出去
的函數,整個進程就直接退出了,打印語句不會執行。因此,maingoroutine不會等待其他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
返回的指令。所以,maingoroutine執行完后,直接調用exit(0)退出整個進程。
那之前整地“偷天換日”還有用嗎?有的!這是針對非maingoroutine起作用。
參考資料【阿波張非goroutine的退出】中用調試工具驗證了非maingoroutine的退出,感興趣的可以去跟著實踐一遍。
我們繼續探索非maingoroutine(后文我們就稱gp好了)的退出流程。
gp
執行完后,RET指令彈出goexit
函數地址(實際上是funcPC(goexit)+1),CPU跳轉到goexit
的第二條指令繼續執行:
//src/runtime/asm_amd64.s//Thetop-mostfunctionrunningonagoroutine
//returnstogoexit+PCQuantum.
TEXTruntime·goexit(SB),NOSPLIT,$0-0
BYTE $0x90 //NOP
CALL runtime·goexit1(SB) //doesnotreturn
//tracebackfromgoexit1musthitcoderangeofgoexit
BYTE $0x90 //NOP
直接調用runtime·goexit1
:
//src/runtime/proc.go
//Finishesexecutionofthecurrentgoroutine.
funcgoexit1(){
//……………………
mcall(goexit0)
}
調用mcall
函數:
//切換到g0棧,執行fn(g)
//Fn不能返回
TEXTruntime·mcall(SB),NOSPLIT,$0-8
//取出參數的值放入DI寄存器,它是funcval對象的指針,此場景中fn.fn是goexit0的地址
MOVQ fn+0(FP),DIget_tls(CX)
//AX=g
MOVQ g(CX),AX //savestateing->sched
//mcall返回地址放入BX
MOVQ 0(SP),BX //caller'sPC
//g.sched.pc=BX,保存g的PC
MOVQ BX,(g_sched+gobuf_pc)(AX)
LEAQ fn+0(FP),BX //caller'sSP
//保存g的SP
MOVQ BX,(g_sched+gobuf_sp)(AX)
MOVQ AX,(g_sched+gobuf_g)(AX)
MOVQ BP,(g_sched+gobuf_bp)(AX)//switchtom->g0&itsstack,callfn
MOVQ g(CX),BX
MOVQ g_m(BX),BX
//SI=g0
MOVQ m_g0(BX),SI
CMPQ SI,AX //ifg==m->g0callbadmcall
JNE3(PC)
MOVQ $runtime·badmcall(SB),AX
JMPAX
//把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
JMPAX
RET
函數參數是:
typefuncvalstruct{
fnuintptr
//variable-size,fn-specificdatahere
}
字段fn就表示goexit0函數的地址。
L5將函數參數保存到DI寄存器,這里fn.fn就是goexit0的地址。
L7將tls保存到CX寄存器,L9將當前線程指向的goroutine(非maingoroutine,稱為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:
//goexitcontinuationong0.
//在g0上執行
funcgoexit0(gp*g){
//g0
_g_:=getg()casgstatus(gp,_Grunning,_Gdead)
ifisSystemGoroutine(gp){
atomic.Xadd(&sched.ngsys,-1)
}//清空gp的一些字段
gp.m=nil
gp.lockedm=nil
_g_.m.lockedg=nil
gp.paniconfault=false
gp._defer=nil//shouldbetruealreadybutjustincase.
gp._panic=nil//non-nilforGoexitduringpanic.pointsatstack-allocateddata.
gp.writebuf=nil
gp.waitreason=""
gp.param=nil
gp.labels=nil
gp.timer=nil//Notethatgp'sstackscanisnow"valid"becauseithasno
//stack.
gp.gcscanvalid=true
//解除g與m的關系
dropg()if_g_.m.locked&^_LockExternal!=0{
print("invalidm->locked=",_g_.m.locked,"\n")
throw("internallockOSThreaderror")
}
_g_.m.locked=0
//將g放入free隊列緩存起來
gfput(_g_.m.p.ptr(),gp)
schedule()
}
它主要完成最后的清理工作:
1.把g的狀態從
_Grunning
更新為_Gdead
;
1.清空g的一些字段;
1.調用dropg函數解除g和m之間的關系,其實就是設置g->m=nil,m->currg=nil;
1.把g放入p的freeg隊列緩存起來供下次創建g時快速獲取而不用從內存分配。freeg就是g的一個對象池;
1.調用schedule函數再次進行調度。
到這里,gp就完成了它的歷史使命,功成身退,進入了goroutine緩存池,待下次有任務再重新啟用。
而工作線程,又繼續調用schedule函數進行新一輪的調度,整個過程形成了一個循環。
總結一下,maingoroutine和普通goroutine的退出過程:
對于maingoroutine,在執行完用戶定義的main函數的所有代碼后,直接調用exit(0)退出整個進程,非常霸道。
對于普通goroutine則沒這么“舒服”,需要經歷一系列的過程。先是跳轉到提前設置好的goexit函數的第二條指令,然后調用runtime.goexit1,接著調用mcall(goexit0)
,而mcall函數會切換到g0棧,運行goexit0函數,清理goroutine的一些字段,并將其添加到goroutine緩存池里,然后進入schedule調度循環。到這里,普通goroutine才算完成使命。
本文節選于Go合集《Go 語言問題集》:GOLANG ROADMAP 一個專注Go語言學習、求職的社區。