天天看點

知道Go第一個Goroutine是如何建立的麽?

還是上篇文章的例子:

package main              ​              import "fmt"              ​              func main() {              fmt.Println("Hello World!")              }
           

接着上篇文章來聊。

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,有兩個參數,新建立出來的goroutine會先從第二個參數fn開始執行,fn可能也會有參數,而newproc第一個參數就是fn的參數,以位元組為機關。

來看如下Go代碼:

func start(a, b, c int64) {              ......              }              ​              func main() {              go start(1, 2, 3)              }
           

編譯器編譯上述代碼時,會将其調整為對newproc的調用,編譯之後的代碼邏輯基本等同于如下代碼案例:

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

編譯器編譯時會首先用幾條指令将start需用到的三個參數壓棧,然後調用newproc。

因為start的三個int64類型參數共占24位元組,是以傳遞給newproc的第一個參數是24,表示start需要24位元組大小的參數。

那為什麼需要傳遞fn的參數大小給newproc呢?

這是因為newproc将建立一個新的goroutine來執行fn,而這個新建立的goroutine與目前的goroutine使用的不是一個棧,是以就需要在建立新的goroutine時就将fn需要用到的參數從目前goroutine棧上拷貝到新的goroutine所使用的棧上,如此才能讓其開始執行,而newproc本身并不知道需要拷貝多少資料到新建立的goroutine的棧上,是以需要使用參數的方式來指定拷貝多少資料。

再來繼續分析newproc,它其實是newproc1的一個包裝。

來看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)              })              }
           

通過上述代碼可以看到,這裡最重要的準備工作有兩個,其一就是擷取fn第一個參數的位址,也就是代碼中的argp,另一個就是使用systemstack切換到g0棧,當然了,本文的初始化的場景本就在g0,是以不需要切換,但這個函數是通用的,在使用者goroutine中,也會再次建立goroutine,此時就需要進行棧的切換。

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狀态如下所示:

知道Go第一個Goroutine是如何建立的麽?

可以看到此時程式中多了個稱為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被排程起來運作時從此位址開始執行指令。

來看下gostartcallfn源碼來聊聊為什麼上述代碼中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,來看下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              }
           

上述代碼主要作用如下:

  1. 調整newg棧空間,将goexit第二條指令入棧,僞造成goexit調用了fn,進而使fn執行完成後調用ret指令傳回到goexit繼續執行完成最後的清理工作。
  2. 重新設定new.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)              ​              ......              }
           

上述代碼比較直覺,先是設定了幾個與排程無關的成員變量,然後修改newg狀态為_Grunnable并将其放入運作隊列,至此程式上真正意義的第一個goroutine建構完成。

此時newg也就是main goroutine狀态如下所示:

知道Go第一個Goroutine是如何建立的麽?

說明如下:

  1. main goroutine對應的newg的sched已初始化完成,上圖隻顯示pc(指向runtime.main的第一條指令)和sp(指向newg的棧頂記憶體單元),sp指向的記憶體單元儲存了runtime.main執行完成後的傳回位址,也就是runtime.goexit的第二條指令,在預期中,runtime.main執行完畢後就回去執行runtime.exit的CALL runtime.goexit1(SB)指令。
  2. newg已放入目前主線程綁定的p的本地運作隊列,因為它是第一個goroutine,是以被放在本地運作隊列的頭部。
  3. newg的m為nil,因為它還沒被排程起來運作,也還未跟任何m進行綁定。

本文主要聊的就是程式第一個goroutine也就是main goroutine的建立,下文再來聊聊它是怎麼被主工作線程排程到CPU去執行的。

以上僅為個人觀點,不一定準确,能幫到各位那是最好的。

好啦,到這裡本文就結束了,喜歡的話就來個三連擊吧。

掃碼關注公衆号,擷取更多優質内容。

知道Go第一個Goroutine是如何建立的麽?