本文是《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条指令,其实就只作了两件事:
把gp.sched的成员恢复到CPU的寄存器完成状态以及栈的切换;
跳转到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函数主要工做流程以下:
启动一个sysmon系统监控线程,该线程负责整个程序的gc、抢占调度以及netpoll等功能的监控,在抢占调度一章咱们再继续分析sysmon是如何协助完成goroutine的抢占调度的;
执行runtime包的初始化;
执行main包以及main包import的全部包的初始化;
执行main.main函数;
从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的流程:
保存g0的调度信息,主要是保存CPU栈顶寄存器SP到g0.sched.sp成员之中;
调用schedule函数寻找须要运行的goroutine,咱们这个场景找到的是main goroutine;
调用gogo函数首先从g0栈切换到main goroutine的栈,而后从main goroutine的g结构体对象之中取出sched.pc的值并使用JMP指令跳转到该地址去执行;
main goroutine执行完毕直接调用exit系统调用退出进程。
下一节咱们将用例子来分析非main goroutine的退出。