Go語言調度器之調度main goroutine(14)

本文是《Go語言調度器源代碼情景分析》系列的第14篇,也是第二章的第4小節。windows


上一節咱們經過分析main goroutine的建立詳細討論了goroutine的建立及初始化流程,這一節咱們接着來分析調度器如何把main goroutine調度到CPU上去運行。本節須要重點關注的問題有:函數

  • 如何保存g0的調度信息?ui

  • schedule函數有什麼重要做用?this

  • gogo函數如何完成從g0到main goroutine的切換?spa

接着前一節繼續分析代碼,從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 rest

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函數到底作了哪些重要工做。code

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條指令,其實就只作了兩件事:

  1. 把gp.sched的成員恢復到CPU的寄存器完成狀態以及棧的切換;

  2. 跳轉到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函數主要工做流程以下:

  1. 啓動一個sysmon系統監控線程,該線程負責整個程序的gc、搶佔調度以及netpoll等功能的監控,在搶佔調度一章咱們再繼續分析sysmon是如何協助完成goroutine的搶佔調度的;

  2. 執行runtime包的初始化;

  3. 執行main包以及main包import的全部包的初始化;

  4. 執行main.main函數;

  5. 從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的流程:

  1. 保存g0的調度信息,主要是保存CPU棧頂寄存器SP到g0.sched.sp成員之中;

  2. 調用schedule函數尋找須要運行的goroutine,咱們這個場景找到的是main goroutine;

  3. 調用gogo函數首先從g0棧切換到main goroutine的棧,而後從main goroutine的g結構體對象之中取出sched.pc的值並使用JMP指令跳轉到該地址去執行;

  4. main goroutine執行完畢直接調用exit系統調用退出進程。

下一節咱們將用例子來分析非main goroutine的退出。

相關文章
相關標籤/搜索