1. 程式人生 > >Go語言排程器之建立main goroutine(13)

Go語言排程器之建立main goroutine(13)

本文是《Go語言排程器原始碼情景分析》系列的第13篇,也是第二章的第3小節。


上一節我們分析了排程器的初始化,這一節我們來看程式中的第一個goroutine是如何建立的。

建立main goroutine

接上一節,schedinit完成排程系統初始化後,返回到rt0_go函式中開始呼叫newproc() 建立一個新的goroutine用於執行mainPC所對應的runtime·main函式,看下面的程式碼:

runtime/asm_amd64.s : 197

# create a new goroutine to start program
MOVQ  $runtime·mainPC(SB), AX# entry,mainPC是runtime.main
# newproc的第二個引數入棧,也就是新的goroutine需要執行的函式
PUSHQ  AX         # AX = &funcval{runtime·main},

# newproc的第一個引數入棧,該引數表示runtime.main函式需要的引數大小,因為runtime.main沒有引數,所以這裡是0
PUSHQ  $0
CALL  runtime·newproc(SB) # 建立main goroutine
POPQ  AX
POPQ  AX

# start this M
CALL  runtime·mstart(SB)  # 主執行緒進入排程迴圈,執行剛剛建立的goroutine

# 上面的mstart永遠不應該返回的,如果返回了,一定是程式碼邏輯有問題,直接abort
CALL  runtime·abort(SB)// mstart should never return
RET

DATA  runtime·mainPC+0(SB)/8,$runtime·main(SB)
GLOB  Lruntime·mainPC(SB),RODATA,$8

 

在後面的分析過程中我們會看到這個runtime.main最終會呼叫我們寫的main.main函式,在分析runtime·main之前我們先把重點放在newproc這個函式上。

newproc函式用於建立新的goroutine,它有兩個引數,先說第二個引數fn,新創建出來的goroutine將從fn這個函式開始執行,而這個fn函式可能也會有引數,newproc的第一個引數正是fn函式的引數以位元組為單位的大小。比如有如下go程式碼片段:

func start(a, b, c int64) {
   ......
}

func main() {
   go start(1, 2, 3)
}

 

編譯器在編譯上面的go語句時,就會把其替換為對newproc函式的呼叫,編譯後的程式碼邏輯上等同於下面的虛擬碼

func main() {
    push 0x3
    push 0x2
    push 0x1
    runtime.newproc(24, start)
}

 

編譯器編譯時首先會用幾條指令把start函式需要用到的3個引數壓棧,然後呼叫newproc函式。因為start函式的3個int64型別的引數共佔24個位元組,所以傳遞給newproc的第一個引數是24,表示start函式需要24位元組大小的引數。

那為什麼需要傳遞fn函式的引數大小給newproc函式呢?原因就在於newproc函式將建立一個新的goroutine來執行fn函式,而這個新建立的goroutine與當前這個goroutine會使用不同的棧,因此就需要在建立goroutine的時候把fn需要用到的引數先從當前goroutine的棧上拷貝到新的goroutine的棧上之後才能讓其開始執行,而newproc函式本身並不知道需要拷貝多少資料到新建立的goroutine的棧上去,所以需要用引數的方式指定拷貝多少資料。

瞭解完這些背景知識之後,下面我們開始分析newproc的程式碼。newproc函式是對newproc1的一個包裝,這裡最重要的準備工作有兩個,一個是獲取fn函式第一個引數的地址(程式碼中的argp),另一個是使用systemstack函式切換到g0棧,當然,對於我們這個初始化場景來說現在本來就在g0棧,所以不需要切換,然而這個函式是通用的,在使用者的goroutine中也會建立goroutine,這時就需要進行棧的切換。

runtime/proc.go : 3232

// Create a new g running fn with siz bytes of arguments.
// Put it on the queue of g's waiting to run.
// The compiler turns a go statement into a call to this.
// Cannot split the stack because it assumes that the arguments
// are available sequentially after &fn; they would not be
// copied if a stack split occurred.
//go:nosplit
func newproc(siz int32, fn *funcval) {
   //函式呼叫引數入棧順序是從右向左,而且棧是從高地址向低地址增長的
    //注意:argp指向fn函式的第一個引數,而不是newproc函式的引數
   //引數fn在棧上的地址+8的位置存放的是fn函式的第一個引數
    argp := add(unsafe.Pointer(&fn), sys.PtrSize)
    gp:= getg()  //獲取正在執行的g,初始化時是m0.g0
   
   //getcallerpc()返回一個地址,也就是呼叫newproc時由call指令壓棧的函式返回地址,
   //對於我們現在這個場景來說,pc就是CALLruntime·newproc(SB)指令後面的POPQ AX這條指令的地址
    pc := getcallerpc()
   
   //systemstack的作用是切換到g0棧執行作為引數的函式
   //我們這個場景現在本身就在g0棧,因此什麼也不做,直接呼叫作為引數的函式
    systemstack(func() {
        newproc1(fn, (*uint8)(argp), siz, gp, pc)
    })
}

 

newproc1函式的第一個引數fn是新建立的goroutine需要執行的函式,注意這個fn的型別是funcval結構體型別,其定義如下:

type  funcval struct{
    fn uintptr
    // variable-size, fn-specific data here
}

 

newproc1的第二個引數argp是fn函式的第一個引數的地址,第三個引數是fn函式的引數以位元組為單位的大小,後面兩個引數我們不用關心。這裡需要注意的是,newproc1是在g0的棧上執行的。該函式很長也很重要,所以我們分段來看。

runtime/proc.go : 3248

// Create a new g running fn with narg bytes of arguments starting
// at argp. callerpc is the address of the go statement that created
// this. The new g is put on the queue of g's waiting to run.
func newproc1(fn *funcval, argp *uint8, narg int32, callergp *g, callerpc uintptr) {
    //因為已經切換到g0棧,所以無論什麼場景都有 _g_ = g0,當然這個g0是指當前工作執行緒的g0
    //對於我們這個場景來說,當前工作執行緒是主執行緒,所以這裡的g0 = m0.g0
    _g_ := getg() 

    ......

    _p_ := _g_.m.p.ptr() //初始化時_p_ = g0.m.p,從前面的分析可以知道其實就是allp[0]
    newg := gfget(_p_) //從p的本地緩衝裡獲取一個沒有使用的g,初始化時沒有,返回nil
    if newg == nil {
         //new一個g結構體物件,然後從堆上為其分配棧,並設定g的stack成員和兩個stackgard成員
        newg = malg(_StackMin)
        casgstatus(newg, _Gidle, _Gdead) //初始化g的狀態為_Gdead
         //放入全域性變數allgs切片中
        allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
    }
   
    ......
   
    //調整g的棧頂置針,無需關注
    totalSize := 4*sys.RegSize+uintptr(siz) +sys.MinFrameSize// extra space in case of reads slightly beyond frame
    totalSize += -totalSize&(sys.SpAlign-1)                  // align to spAlign
    sp := newg.stack.hi-totalSize
    spArg := sp

    ......
   
    if narg > 0 {
         //把引數從執行newproc函式的棧(初始化時是g0棧)拷貝到新g的棧
        memmove(unsafe.Pointer(spArg), unsafe.Pointer(argp), uintptr(narg))
        // ......
    }

 

這段程式碼主要從堆上分配一個g結構體物件併為這個newg分配一個大小為2048位元組的棧,並設定好newg的stack成員,然後把newg需要執行的函式的引數從執行newproc函式的棧(初始化時是g0棧)拷貝到newg的棧,完成這些事情之後newg的狀態如下圖所示:

我們可以看到,經過前面的程式碼之後,程式中多了一個我們稱之為newg的g結構體物件,該物件也已經獲得了從堆上分配而來的2k大小的棧空間,newg的stack.hi和stack.lo分別指向了其棧空間的起止位置。

接下來我們繼續分析newproc1函式。

runtime/proc.go : 3314

 
//把newg.sched結構體成員的所有成員設定為0
    memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
   
    //設定newg的sched成員,排程器需要依靠這些欄位才能把goroutine排程到CPU上執行。
    newg.sched.sp = sp //newg的棧頂
    newg.stktopsp = sp
    //newg.sched.pc表示當newg被排程起來執行時從這個地址開始執行指令
    //把pc設定成了goexit這個函式偏移1(sys.PCQuantum等於1)的位置,
    //至於為什麼要這麼做需要等到分析完gostartcallfn函式才知道
    newg.sched.pc = funcPC(goexit) + sys.PCQuantum// +PCQuantum so that previous instruction is in same function
    newg.sched.g = guintptr(unsafe.Pointer(newg))

    gostartcallfn(&newg.sched, fn)//調整sched成員和newg的棧

 

這段程式碼首先對newg的sched成員進行了初始化,該成員包含了排程器程式碼在排程goroutine到CPU執行時所必須的一些資訊,其中sched的sp成員表示newg被排程起來執行時應該使用的棧的棧頂,sched的pc成員表示當newg被排程起來執行時從這個地址開始執行指令,然而從上面的程式碼可以看到,new.sched.pc被設定成了goexit函式的第二條指令的地址而不是fn.fn,這是為什麼呢?要回答這個問題,必須深入到gostartcallfn函式中做進一步分析。

// adjust Gobuf as if it executed a call to fn
// and then did an immediate gosave.
func gostartcallfn(gobuf *gobuf, fv *funcval) {
    var fn unsafe.Pointer
    if fv != nil {
       fn = unsafe.Pointer(fv.fn) //fn: gorotine的入口地址,初始化時對應的是runtime.main
    } else {
        fn = unsafe.Pointer(funcPC(nilfunc))
    }
    gostartcall(gobuf, fn, unsafe.Pointer(fv))
}

 

gostartcallfn首先從引數fv中提取出函式地址(初始化時是runtime.main),然後繼續呼叫gostartcall函式。

// adjust Gobuf as if it executed a call to fn with context ctxt
// and then did an immediate gosave.
func gostartcall(buf *gobuf, fn, ctxt unsafe.Pointer) {
    sp := buf.sp//newg的棧頂,目前newg棧上只有fn函式的引數,sp指向的是fn的第一引數
    if sys.RegSize > sys.PtrSize {
        sp -= sys.PtrSize
        *(*uintptr)(unsafe.Pointer(sp)) = 0
    }
    sp -= sys.PtrSize//為返回地址預留空間,
    //這裡在偽裝fn是被goexit函式呼叫的,使得fn執行完後返回到goexit繼續執行,從而完成清理工作
    *(*uintptr)(unsafe.Pointer(sp)) = buf.pc//在棧上放入goexit+1的地址
    buf.sp = sp//重新設定newg的棧頂暫存器
    //這裡才真正讓newg的ip暫存器指向fn函式,注意,這裡只是在設定newg的一些資訊,newg還未執行,
   //等到newg被排程起來執行時,排程器會把buf.pc放入cpu的IP暫存器,
    //從而使newg得以在cpu上真正的執行起來
    buf.pc = uintptr(fn) 
    buf.ctxt = ctxt
}

 

gostartcall函式的主要作用有兩個:

  1. 調整newg的棧空間,把goexit函式的第二條指令的地址入棧,偽造成goexit函式呼叫了fn,從而使fn執行完成後執行ret指令時返回到goexit繼續執行完成最後的清理工作;

  2. 重新設定newg.buf.pc 為需要執行的函式的地址,即fn,我們這個場景為runtime.main函式的地址。

調整完成newg的棧和sched成員之後,返回到newproc1函式,我們繼續往下看,

   
    newg.gopc = callerpc //主要用於traceback
    newg.ancestors = saveAncestors(callergp)
    //設定newg的startpc為fn.fn,該成員主要用於函式呼叫棧的traceback和棧收縮
    //newg真正從哪裡開始執行並不依賴於這個成員,而是sched.pc
    newg.startpc = fn.fn 

    ......
   
   //設定g的狀態為_Grunnable,表示這個g代表的goroutine可以運行了
    casgstatus(newg, _Gdead, _Grunnable)

    ......
   
    //把newg放入_p_的執行佇列,初始化的時候一定是p的本地執行佇列,其它時候可能因為本地佇列滿了而放入全域性佇列
    runqput(_p_, newg, true)

    ......
}

 

newproc1函式最後這點程式碼比較直觀,首先設定了幾個與排程無關的成員變數,然後修改newg的狀態為_Grunnable並把其放入了執行佇列,到此程式中第一個真正意義上的goroutine已經建立完成。

這時newg也就是main goroutine的狀態如下圖所示:

這個圖看起來比較複雜,因為表示指標的箭頭實在是太多了,這裡對其稍作一下解釋。

  • 首先,main goroutine對應的newg結構體物件的sched成員已經完成了初始化,圖中只顯示了pc和sp成員,pc成員指向了runtime.main函式的第一條指令,sp成員指向了newg的棧頂記憶體單元,該記憶體單元儲存了runtime.main函式執行完成之後的返回地址,也就是runtime.goexit函式的第二條指令,預期runtime.main函式執行完返回之後就會去執行runtime.exit函式的CALL runtime.goexit1(SB)這條指令;

  • 其次,newg已經放入與當前主執行緒繫結的p結構體物件的本地執行佇列,因為它是第一個真正意義上的goroutine,還沒有其它goroutine,所以它被放在了本地執行佇列的頭部;

  • 最後,newg的m成員為nil,因為它還沒有被排程起來執行,也就沒有跟任何m進行繫結。

這一節我們分析了程式中第一個goroutine也就是main goroutine的建立,下一節我們繼續分析它是怎麼被主工作執行緒排程到CPU上去執行