简单聊聊调度器是如何将main goroutine调度到CPU去执行的~

​通过《Goroutine调度器初始化过程详解》和《知道Go第一个Goroutine是如何创建的麽?》两文了解了goroutine的创建和初始化流程,本文来看下调度器是如何将main goroutine调度到CPU去执行的。

本文需要关注的问题如下:

  1. 如何保存g0的调度信息?

  2. schedule函数有什么用?

  3. gogo函数如何完成从g0到main goroutine的切换?

接着上篇文章来分析。

上文介绍到从newproc返回到rt0_g0,继续执行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调度信息,要注意的是,getcallerpc函数返回的是mstart调用mstart1时被call指令压栈的返回地址,getcallersp函数返回的是mstart调用mstart1之前mstart的栈顶地址。

来看runtime/proc.go文件2733行代码研究save究竟做了什么工作:

// 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:nowritebarrierrecfunc 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语句。

save执行完成后返回到mstart1继续执行其它跟m相关的一些初始化,完成这部分初始化之后调用调度系统的核心schedule完成goroutine调度。

为什么说schedule是核心呢?

原因就在于每次goroutine调度时,都是从此函数开始的。

来看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,如果这两个运行队列都没有,则通过findrunnable从其它p的运行队列中盗取goroutine,一旦找到下一个goroutine,则调用execute,从g0切换到该goroutine去运行。

对于当前场景来说,之前的启动流程已经创建好第一个goroutine并放入当前工作线程的本地运行队列,所以这里会通过runqget将目前唯一一个goroutine取出来,接下来分析下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:yeswritebarrierrecfunc 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; longjmpTEXT 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寄存器所指的位置,所以第一条指令【MOVQ    buf+0(FP), BX  # &gp.sched --> BX】将buf的值,也是gp.sched的地址放在BX寄存器之中,如此,便于后面指令依据BX来存取gp.sched的成员,sched保存了调度相关信息,main goroutine创建时已设置完毕。

第二条指令【MOVQ   gobuf_g(BX), DX  # gp.sched.g --> DX】将gp.sched.g读取到DX寄存器,这条指令的源操作数,是间接寻址。

第三条指令【MOVQ   0(DX), CX # make sure g != nil】用于检查gp.sched.g是否为nil,因为gp.sched.g是由Go runtime负责设置的,如果为nil指针,则表示程序有问题,所以会panic,将问题暴露出来。

第四条指令【get_tls(CX)】和第五条指令【MOVQ  DX, g(CX)】将DX寄存器的值也就是gp.sched.g(指向g的指针)写入线程本地存储(TLS),如此,后面的代码就可以通过TLS获取当前正在执行的goroutine的g结构体对象,从而找到关联的m、p。

第六条指令设置CPU栈顶寄存器SP为gp.sched.sp,此指令完成了栈从g0到gp的切换。

第七到十三条指令首先根据gp.sched其他成员变量设置CPU的相关寄存器,可以看到这里恢复了CPU栈基寄存器BP,之后是将gp.sched中已经不需要的成员设置为0,可以减少gc的工作量。

第十四条指令将gp.sched.pc的值读取到BX寄存器,此值是gp即将要执行的第一条指令的地址,对于当前场景来说就是runtime.main的第一条指令,现在这条指令的地址就存放在BX寄存器中。

最后一条指令将BX的值放入CPU的rip寄存器中,于是CPU就跳到这个值所指的地址去执行gp,完成了goroutine的切换。此指令只做了两件事,第一就是将gp.sched恢复到CPU的寄存器完成状态以及栈的切换,第二就是跳转到gp.sched.pc所指的指令地址,也就是runtime.main处执行。

目前已经从g0切换到gp,对于当前场景来说,gp是第一次被调度起来执行,由于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等功能的监控。

  2. 执行runtime包的初始化。

  3. 执行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时,已经在其栈上放好了一个返回地址,伪造成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系统调用退出程序。

至此,Go第一个Goroutine是如何被调度器调度到CPU去执行的相关内容就介绍完毕了,下篇文章来聊聊非main goroutine的退出流程和调度循环。

以上仅为个人观点,不一定准确,能帮到各位那是最好的。

好啦,到这里本文就结束了,喜欢的话就来个三连击吧。

扫码关注公众号,获取更多优质内容。

Guess you like

Origin blog.csdn.net/luyaran/article/details/120859051