Go語言排程器之排程main goroutine(14)

愛寫程式的阿波張發表於2019-05-09

本文是《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條指令,其實就只做了兩件事:

  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的退出。

相關文章