本文是《Go语言调度器源代码情景分析》系列的第13篇,也是第二章的第3小节。函数
上一节咱们分析了调度器的初始化,这一节咱们来看程序中的第一个goroutine是如何建立的。ui
接上一节,schedinit完成调度系统初始化后,返回到rt0_go函数中开始调用newproc() 建立一个新的goroutine用于执行mainPC所对应的runtime·main函数,看下面的代码:this
runtime/asm_amd64.s : 197spa
# 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代码片断:3d
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字节大小的参数。code
那为何须要传递fn函数的参数大小给newproc函数呢?缘由就在于newproc函数将建立一个新的goroutine来执行fn函数,而这个新建立的goroutine与当前这个goroutine会使用不一样的栈,所以就须要在建立goroutine的时候把fn须要用到的参数先从当前goroutine的栈上拷贝到新的goroutine的栈上以后才能让其开始执行,而newproc函数自己并不知道须要拷贝多少数据到新建立的goroutine的栈上去,因此须要用参数的方式指定拷贝多少数据。对象
了解完这些背景知识以后,下面咱们开始分析newproc的代码。newproc函数是对newproc1的一个包装,这里最重要的准备工做有两个,一个是获取fn函数第一个参数的地址(代码中的argp),另外一个是使用systemstack函数切换到g0栈,固然,对于咱们这个初始化场景来讲如今原本就在g0栈,因此不须要切换,然而这个函数是通用的,在用户的goroutine中也会建立goroutine,这时就须要进行栈的切换。blog
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函数的主要做用有两个:
调整newg的栈空间,把goexit函数的第二条指令的地址入栈,伪形成goexit函数调用了fn,从而使fn执行完成后执行ret指令时返回到goexit继续执行完成最后的清理工做;
从新设置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上去执行的。