本文是《Go語言調度器源代碼情景分析》系列的第14篇,也是第二章的第4小節。
上一節我們通過分析main goroutine的創建詳細討論了goroutine的創建及初始化流程,這一節我們接着來分析調度器如何把main goroutine調度到CPU上去運行。本節需要重點關注的問題有:
-
如何保存g0的調度信息?
-
schedule函數有什么重要作用?
-
gogo函數如何完成從g0到main goroutine的切換?
接着前一節繼續分析代碼,從newproc返回到rt0_go,繼續往下執行mstart。
runtime/proc.go : 1153
func mstart() { _g_ := getg() //_g_ = g0 //對於啟動過程來說,g0的stack.lo早已完成初始化,所以onStack = false osStack := _g_.stack.lo == 0 if osStack { // Initialize stack bounds from system stack. // Cgo may have left stack size in stack.hi. // minit may update the stack bounds. size := _g_.stack.hi if size == 0 { size = 8192 * sys.StackGuardMultiplier } _g_.stack.hi = uintptr(noescape(unsafe.Pointer(&size))) _g_.stack.lo = _g_.stack.hi - size + 1024 } // Initialize stack guards so that we can start calling // both Go and C functions with stack growth prologues. _g_.stackguard0 = _g_.stack.lo + _StackGuard _g_.stackguard1 = _g_.stackguard0 mstart1() // Exit this thread. if GOOS == "windows" || GOOS == "solaris" || GOOS == "plan9" || GOOS == "darwin" || GOOS == "aix" { // Window, Solaris, Darwin, AIX and Plan 9 always system-allocate // the stack, but put it in _g_.stack before mstart, // so the logic above hasn't set osStack yet. osStack = true } mexit(osStack) }
mstart函數本身沒啥說的,它繼續調用mstart1函數。
runtime/proc.go : 1184
func mstart1() { _g_ := getg() //啟動過程時 _g_ = m0的g0 if _g_ != _g_.m.g0 { throw("bad runtime·mstart") } // Record the caller for use as the top of stack in mcall and // for terminating the thread. // We're never coming back to mstart1 after we call schedule, // so other calls can reuse the current frame. //getcallerpc()獲取mstart1執行完的返回地址 //getcallersp()獲取調用mstart1時的棧頂地址 save(getcallerpc(), getcallersp()) asminit() //在AMD64 Linux平台中,這個函數什么也沒做,是個空函數 minit() //與信號相關的初始化,目前不需要關心 // Install signal handlers; after minit so that minit can // prepare the thread to be able to handle the signals. if _g_.m == &m0 { //啟動時_g_.m是m0,所以會執行下面的mstartm0函數 mstartm0() //也是信號相關的初始化,現在我們不關注 } if fn := _g_.m.mstartfn; fn != nil { //初始化過程中fn == nil fn() } if _g_.m != &m0 {// m0已經綁定了allp[0],不是m0的話還沒有p,所以需要獲取一個p acquirep(_g_.m.nextp.ptr()) _g_.m.nextp = 0 } //schedule函數永遠不會返回 schedule() }
mstart1首先調用save函數來保存g0的調度信息,save這一行代碼非常重要,是我們理解調度循環的關鍵點之一。這里首先需要注意的是代碼中的getcallerpc()返回的是mstart調用mstart1時被call指令壓棧的返回地址,getcallersp()函數返回的是調用mstart1函數之前mstart函數的棧頂地址,其次需要看看save函數到底做了哪些重要工作。
runtime/proc.go : 2733
// save updates getg().sched to refer to pc and sp so that a following // gogo will restore pc and sp. // // save must not have write barriers because invoking a write barrier // can clobber getg().sched. // //go:nosplit //go:nowritebarrierrec func save(pc, sp uintptr) { _g_ := getg() _g_.sched.pc = pc //再次運行時的指令地址 _g_.sched.sp = sp //再次運行時到棧頂 _g_.sched.lr = 0 _g_.sched.ret = 0 _g_.sched.g = guintptr(unsafe.Pointer(_g_)) // We need to ensure ctxt is zero, but can't have a write // barrier here. However, it should always already be zero. // Assert that. if _g_.sched.ctxt != nil { badctxt() } }
可以看到,save函數保存了調度相關的所有信息,包括最為重要的當前正在運行的g的下一條指令的地址和棧頂地址,不管是對g0還是其它goroutine來說這些信息在調度過程中都是必不可少的,我們會在后面的調度分析中看到調度器是如何利用這些信息來完成調度的。代碼執行完save函數之后g0的狀態如下圖所示:
從上圖可以看出,g0.sched.sp指向了mstart1函數執行完成后的返回地址,該地址保存在了mstart函數的棧幀之中;g0.sched.pc指向的是mstart函數中調用mstart1函數之后的 if 語句。
為什么g0已經執行到mstart1這個函數了而且還會繼續調用其它函數,但g0的調度信息中的pc和sp卻要設置在mstart函數中?難道下次切換到g0時要從mstart函數中的 if 語句繼續執行?可是從mstart函數可以看到,if語句之后就要退出線程了!這看起來很奇怪,不過隨着分析的進行,我們會看到這里為什么要這么做。
繼續分析代碼,save函數執行完成后,返回到mstart1繼續其它跟m相關的一些初始化,完成這些初始化后則調用調度系統的核心函數schedule()完成goroutine的調度,之所以說它是核心,原因在於每次調度goroutine都是從schedule函數開始的。
runtime/proc.go : 2469
// One round of scheduler: find a runnable goroutine and execute it. // Never returns. func schedule() { _g_ := getg() //_g_ = 每個工作線程m對應的g0,初始化時是m0的g0 //...... var gp *g //...... if gp == nil { // Check the global runnable queue once in a while to ensure fairness. // Otherwise two goroutines can completely occupy the local runqueue // by constantly respawning each other. //為了保證調度的公平性,每進行61次調度就需要優先從全局運行隊列中獲取goroutine, //因為如果只調度本地隊列中的g,那么全局運行隊列中的goroutine將得不到運行 if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 { lock(&sched.lock) //所有工作線程都能訪問全局運行隊列,所以需要加鎖 gp = globrunqget(_g_.m.p.ptr(), 1) //從全局運行隊列中獲取1個goroutine unlock(&sched.lock) } } if gp == nil { //從與m關聯的p的本地運行隊列中獲取goroutine gp, inheritTime = runqget(_g_.m.p.ptr()) if gp != nil && _g_.m.spinning { throw("schedule: spinning with local work") } } if gp == nil { //如果從本地運行隊列和全局運行隊列都沒有找到需要運行的goroutine, //則調用findrunnable函數從其它工作線程的運行隊列中偷取,如果偷取不到,則當前工作線程進入睡眠, //直到獲取到需要運行的goroutine之后findrunnable函數才會返回。 gp, inheritTime = findrunnable() // blocks until work is available } //跟啟動無關的代碼..... //當前運行的是runtime的代碼,函數調用棧使用的是g0的棧空間 //調用execte切換到gp的代碼和棧空間去運行 execute(gp, inheritTime) }
schedule函數通過調用globrunqget()和runqget()函數分別從全局運行隊列和當前工作線程的本地運行隊列中選取下一個需要運行的goroutine,如果這兩個隊列都沒有需要運行的goroutine則通過findrunnalbe()函數從其它p的運行隊列中盜取goroutine,一旦找到下一個需要運行的goroutine,則調用excute函數從g0切換到該goroutine去運行。對於我們這個場景來說,前面的啟動流程已經創建好第一個goroutine並放入了當前工作線程的本地運行隊列,所以這里會通過runqget把目前唯一的一個goroutine取出來,至於具體是如何取出來的,我們將在第三章討論調度策略時再回頭來詳細分析globrunqget(),runqget()和findrunnable()這三個函數的實現流程,現在我們先來分析execute函數是如何把從運行隊列中找出來的goroutine調度到CPU上運行的。
runtime/proc.go : 2136
// Schedules gp to run on the current M. // If inheritTime is true, gp inherits the remaining time in the // current time slice. Otherwise, it starts a new time slice. // Never returns. // // Write barriers are allowed because this is called immediately after // acquiring a P in several places. // //go:yeswritebarrierrec func execute(gp *g, inheritTime bool) { _g_ := getg() //g0 //設置待運行g的狀態為_Grunning casgstatus(gp, _Grunnable, _Grunning) //...... //把g和m關聯起來 _g_.m.curg = gp gp.m = _g_.m //...... //gogo完成從g0到gp真正的切換 gogo(&gp.sched) }
execute函數的第一個參數gp即是需要調度起來運行的goroutine,這里首先把gp的狀態從_Grunnable修改為_Grunning,然后把gp和m關聯起來,這樣通過m就可以找到當前工作線程正在執行哪個goroutine,反之亦然。
完成gp運行前的准備工作之后,execute調用gogo函數完成從g0到gp的的切換:CPU執行權的轉讓以及棧的切換。
gogo函數也是通過匯編語言編寫的,這里之所以需要使用匯編,是因為goroutine的調度涉及不同執行流之間的切換,前面我們在討論操作系統切換線程時已經看到過,執行流的切換從本質上來說就是CPU寄存器以及函數調用棧的切換,然而不管是go還是c這種高級語言都無法精確控制CPU寄存器的修改,因而高級語言在這里也就無能為力了,只能依靠匯編指令來達成目的。
runtime/asm_amd64.s : 251
# func gogo(buf *gobuf) # restore state from Gobuf; longjmp TEXT runtime·gogo(SB), NOSPLIT, $16-8 #buf = &gp.sched MOVQ buf+0(FP), BX # BX = buf #gobuf->g --> dx register MOVQ gobuf_g(BX), DX # DX = gp.sched.g #下面這行代碼沒有實質作用,檢查gp.sched.g是否是nil,如果是nil進程會crash死掉 MOVQ 0(DX), CX # make sure g != nil get_tls(CX) #把要運行的g的指針放入線程本地存儲,這樣后面的代碼就可以通過線程本地存儲 #獲取到當前正在執行的goroutine的g結構體對象,從而找到與之關聯的m和p MOVQ DX, g(CX) #把CPU的SP寄存器設置為sched.sp,完成了棧的切換 MOVQ gobuf_sp(BX), SP # restore SP #下面三條同樣是恢復調度上下文到CPU相關寄存器 MOVQ gobuf_ret(BX), AX MOVQ gobuf_ctxt(BX), DX MOVQ gobuf_bp(BX), BP #清空sched的值,因為我們已把相關值放入CPU對應的寄存器了,不再需要,這樣做可以少gc的工作量 MOVQ $0, gobuf_sp(BX) # clear to help garbage collector MOVQ $0, gobuf_ret(BX) MOVQ $0, gobuf_ctxt(BX) MOVQ $0, gobuf_bp(BX) #把sched.pc值放入BX寄存器 MOVQ gobuf_pc(BX), BX #JMP把BX寄存器的包含的地址值放入CPU的IP寄存器,於是,CPU跳轉到該地址繼續執行指令, JMP BX
gogo函數的這段匯編代碼短小而強悍,雖然筆者已經在代碼中做了詳細的注釋,但為了完全搞清楚它的工作原理,我們有必要再對這些指令進行逐條分析:
execute函數在調用gogo時把gp的sched成員的地址作為實參(型參buf)傳遞了過來,該參數位於FP寄存器所指的位置,所以第1條指令
MOVQ buf+0(FP), BX # &gp.sched --> BX
把buf的值也就是gp.sched的地址放在了BX寄存器之中,這樣便於后面的指令依靠BX寄存器來存取gp.sched的成員。sched成員保存了調度相關的信息,上一節我們已經看到,main goroutine創建時已經把這些信息設置好了。
第2條指令
MOVQ gobuf_g(BX), DX # gp.sched.g --> DX
把gp.sched.g讀取到DX寄存器,注意這條指令的源操作數是間接尋址,如果讀者對間接尋址不熟悉的話可以參考預備知識匯編語言部分。
第3條指令
MOVQ 0(DX), CX # make sure g != nil
的作用在於檢查gp.sched.g是否為nil,如果為nil指針的話,這條指令會導致程序死掉,有讀者可能會有疑問,為什么要讓它死掉啊,原因在於這個gp.sched.g是由go runtime代碼負責設置的,按道理說不可能為nil,如果為nil,一定是程序邏輯寫得有問題,所以需要把這個bug暴露出來,而不是把它隱藏起來。
第4條和第5條指令
get_tls(CX)
#把DX值也就是需要運行的goroutine的指針寫入線程本地存儲之中
#運行這條指令之前,線程本地存儲存放的是g0的地址
MOVQ DX, g(CX)
把DX寄存器的值也就是gp.sched.g(這是一個指向g的指針)寫入線程本地存儲之中,這樣后面的代碼就可以通過線程本地存儲獲取到當前正在執行的goroutine的g結構體對象,從而找到與之關聯的m和p。
第6條指令
MOVQ gobuf_sp(BX), SP # restore SP
設置CPU的棧頂寄存器SP為gp.sched.sp,這條指令完成了棧的切換,從g0的棧切換到了gp的棧。
第7~13條指令
#下面三條同樣是恢復調度上下文到CPU相關寄存器 MOVQ gobuf_ret(BX), AX #系統調用的返回值放入AX寄存器 MOVQ gobuf_ctxt(BX), DX MOVQ gobuf_bp(BX), BP #清空gp.sched中不再需要的值,因為我們已把相關值放入CPU對應的寄存器了,不再需要,這樣做可以少gc的工作量 MOVQ $0, gobuf_sp(BX) // clear to help garbage collector MOVQ $0, gobuf_ret(BX) MOVQ $0, gobuf_ctxt(BX) MOVQ $0, gobuf_bp(BX)
一是根據gp.sched其它字段設置CPU相關寄存器,可以看到這里恢復了CPU的棧基地址寄存器BP,二是把gp.sched中已經不需要的成員設置為0,這樣可以減少gc的工作量。
第14條指令
MOVQ gobuf_pc(BX), BX
把gp.sched.pc的值讀取到BX寄存器,這個pc值是gp這個goroutine馬上需要執行的第一條指令的地址,對於我們這個場景來說它現在就是runtime.main函數的第一條指令,現在這條指令的地址就放在BX寄存器里面。最后一條指令
JMP BX
這里的JMP BX指令把BX寄存器里面的指令地址放入CPU的rip寄存器,於是,CPU就會跳轉到該地址繼續執行屬於gp這個goroutine的代碼,這樣就完成了goroutine的切換。
總結一下這15條指令,其實就只做了兩件事:
-
把gp.sched的成員恢復到CPU的寄存器完成狀態以及棧的切換;
-
跳轉到gp.sched.pc所指的指令地址(runtime.main)處執行。
現在已經從g0切換到了gp這個goroutine,對於我們這個場景來說,gp還是第一次被調度起來運行,它的入口函數是runtime.main,所以接下來CPU就開始執行runtime.main函數:
runtime/proc.go : 109
// The main goroutine. func main() { g := getg() // g = main goroutine,不再是g0了 // ...... // Max stack size is 1 GB on 64-bit, 250 MB on 32-bit. // Using decimal instead of binary GB and MB because // they look nicer in the stack overflow failure message. if sys.PtrSize == 8 { //64位系統上每個goroutine的棧最大可達1G maxstacksize = 1000000000 } else { maxstacksize = 250000000 } // Allow newproc to start new Ms. mainStarted = true if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon //現在執行的是main goroutine,所以使用的是main goroutine的棧,需要切換到g0棧去執行newm() systemstack(func() { //創建監控線程,該線程獨立於調度器,不需要跟p關聯即可運行 newm(sysmon, nil) }) } //...... //調用runtime包的初始化函數,由編譯器實現 runtime_init() // must be before defer // Record when the world started. runtimeInitTime = nanotime() gcenable() //開啟垃圾回收器 //...... //main 包的初始化函數,也是由編譯器實現,會遞歸的調用我們import進來的包的初始化函數 fn := main_init // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime fn() //...... //調用main.main函數 fn = main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime fn() //...... //進入系統調用,退出進程,可以看出main goroutine並未返回,而是直接進入系統調用退出進程了 exit(0) //保護性代碼,如果exit意外返回,下面的代碼也會讓該進程crash死掉 for { var x *int32 *x = 0 } }
runtime.main函數主要工作流程如下:
-
啟動一個sysmon系統監控線程,該線程負責整個程序的gc、搶占調度以及netpoll等功能的監控,在搶占調度一章我們再繼續分析sysmon是如何協助完成goroutine的搶占調度的;
-
執行runtime包的初始化;
-
執行main包以及main包import的所有包的初始化;
-
執行main.main函數;
-
從main.main函數返回后調用exit系統調用退出進程;
從上述流程可以看出,runtime.main執行完main包的main函數之后就直接調用exit系統調用結束進程了,它並沒有返回到調用它的函數(還記得是從哪里開始執行的runtime.main嗎?),其實runtime.main是main goroutine的入口函數,並不是直接被調用的,而是在schedule()->execute()->gogo()這個調用鏈的gogo函數中用匯編代碼直接跳轉過來的,所以從這個角度來說,goroutine確實不應該返回,沒有地方可返回啊!可是從前面的分析中我們得知,在創建goroutine的時候已經在其棧上放好了一個返回地址,偽造成goexit函數調用了goroutine的入口函數,這里怎么沒有用到這個返回地址啊?其實那是為非main goroutine准備的,非main goroutine執行完成后就會返回到goexit繼續執行,而main goroutine執行完成后整個進程就結束了,這是main goroutine與其它goroutine的一個區別。
總結一下從g0切換到main goroutine的流程:
-
保存g0的調度信息,主要是保存CPU棧頂寄存器SP到g0.sched.sp成員之中;
-
調用schedule函數尋找需要運行的goroutine,我們這個場景找到的是main goroutine;
-
調用gogo函數首先從g0棧切換到main goroutine的棧,然后從main goroutine的g結構體對象之中取出sched.pc的值並使用JMP指令跳轉到該地址去執行;
-
main goroutine執行完畢直接調用exit系統調用退出進程。
下一節我們將用例子來分析非main goroutine的退出。