非main goroutine的退出流程和调度循环

原文地址:非main goroutine的退出流程和调度循环

简单聊聊调度器是如何将main goroutine调度到CPU去执行的~》一文聊到main goroutine退出时会直接调用exit系统调用退出进程,而非main goroutine退出时则会进入goexit完成最后的清理工作,本文就来聊聊非main goroutine是如何返回到goexit以及mcall如何从用户goroutine切到g0继续执行以及调度循环这些内容。

先看段简单的代码:

package main
import (    "fmt")
func g2(n int, ch chan int) {
   
       ch <- n * n}
func main() {
   
       ch := make(chan int)
    go g2(100, ch)
    fmt.Println(<-ch)}

上述代码逻辑非常简单,可以理解为main goroutine启动main后创建一个goroutine执行g2,可称之为g2 goroutine,接下来使用gdb中backtrace查看g2是被谁调用的,之后单步执行,看其执行完毕后能否返回goexit继续执行。

gdb调试过程如下:

(gdb) b main.g2       // 在main.g2函数入口处下断点Breakpoint 1 at 0x4869c0: file /home/bobo/study/go/goexit.go, line 7.(gdb) rStarting program: /home/bobo/study/go/goexit Thread 1 "goexit" hit Breakpoint 1 at /home/bobo/study/go/goexit.go:7(gdb) bt       //查看函数调用链,看起来g2真的是被runtime.goexit调用的#0 main.g2 (n=100, ch=0xc000052060) at /home/bobo/study/go/goexit.go:7#1 0x0000000000450ad1 in runtime.goexit () at /usr/local/go/src/runtime/asm_amd64.s:1337(gdb) disass     //反汇编找ret的地址,这是为了在ret处下断点Dump of assembler code for function main.g2:=> 0x00000000004869c0 <+0>:mov   %fs:0xfffffffffffffff8,%rcx  0x00000000004869c9 <+9>:cmp   0x10(%rcx),%rsp  0x00000000004869cd <+13>:jbe   0x486a0d <main.g2+77>  0x00000000004869cf <+15>:sub   $0x20,%rsp  0x00000000004869d3 <+19>:mov   %rbp,0x18(%rsp)  0x00000000004869d8 <+24>:lea   0x18(%rsp),%rbp  0x00000000004869dd <+29>:mov   0x28(%rsp),%rax  0x00000000004869e2 <+34>:imul   %rax,%rax  0x00000000004869e6 <+38>:mov   %rax,0x10(%rsp)  0x00000000004869eb <+43>:mov   0x30(%rsp),%rax  0x00000000004869f0 <+48>:mov   %rax,(%rsp)  0x00000000004869f4 <+52>:lea   0x10(%rsp),%rax  0x00000000004869f9 <+57>:mov   %rax,0x8(%rsp)  0x00000000004869fe <+62>:callq 0x4046a0 <runtime.chansend1>  0x0000000000486a03 <+67>:mov   0x18(%rsp),%rbp  0x0000000000486a08 <+72>:add   $0x20,%rsp  0x0000000000486a0c <+76>:retq     0x0000000000486a0d <+77>:callq 0x44ece0 <runtime.morestack_noctxt>  0x0000000000486a12 <+82>:jmp   0x4869c0 <main.g2>End of assembler dump.(gdb) b *0x0000000000486a0c             //在retq指令位置下断点Breakpoint 2 at 0x486a0c: file /home/bobo/study/go/goexit.go, line 9.(gdb) cContinuing.Thread 1 "goexit" hit Breakpoint 2 at /home/bobo/study/go/goexit.go:9(gdb) disass             //程序停在了ret指令处Dump of assembler code for function main.g2:  0x00000000004869c0 <+0>:mov   %fs:0xfffffffffffffff8,%rcx  0x00000000004869c9 <+9>:cmp   0x10(%rcx),%rsp  0x00000000004869cd <+13>:jbe   0x486a0d <main.g2+77>  0x00000000004869cf <+15>:sub   $0x20,%rsp  0x00000000004869d3 <+19>:mov   %rbp,0x18(%rsp)  0x00000000004869d8 <+24>:lea   0x18(%rsp),%rbp  0x00000000004869dd <+29>:mov   0x28(%rsp),%rax  0x00000000004869e2 <+34>:imul   %rax,%rax  0x00000000004869e6 <+38>:mov   %rax,0x10(%rsp)  0x00000000004869eb <+43>:mov   0x30(%rsp),%rax  0x00000000004869f0 <+48>:mov   %rax,(%rsp)  0x00000000004869f4 <+52>:lea   0x10(%rsp),%rax  0x00000000004869f9 <+57>:mov   %rax,0x8(%rsp)  0x00000000004869fe <+62>:callq 0x4046a0 <runtime.chansend1>  0x0000000000486a03 <+67>:mov   0x18(%rsp),%rbp  0x0000000000486a08 <+72>:add   $0x20,%rsp=> 0x0000000000486a0c <+76>:retq     0x0000000000486a0d <+77>:callq 0x44ece0 <runtime.morestack_noctxt>  0x0000000000486a12 <+82>:jmp   0x4869c0 <main.g2>End of assembler dump.(gdb) si         //单步执行一条指令runtime.goexit () at /usr/local/go/src/runtime/asm_amd64.s:13381338CALLruntime·goexit1(SB)// does not return(gdb) disass           //可以看出来g2已经返回到了goexit函数中Dump of assembler code for function runtime.goexit:  0x0000000000450ad0 <+0>:nop=> 0x0000000000450ad1 <+1>:callq 0x42faf0 <runtime.goexit1>  0x0000000000450ad6 <+6>:nop

先在g2入口处下一个断点,程序暂停后查看函数调用栈可发现g2确实被goexit调用,之后再次使用断点,让程序停在g2返回前最后一条指令retq处,单步执行后可看到程序从g2返回到goexit第二条指令处,此处正是当初创建goroutine设置好的返回地址。

由上述推断可看出g2不是被goexit直接调用,是其执行完返回到goexit中。

来看goexit,runtime/asm_amd64.s文件1334行代码:

// The top-most function running on a goroutine// returns to goexit+PCQuantum.TEXT runtime·goexit(SB),NOSPLIT,$0-0    BYTE  $0x90  // NOP    CALL  runtime·goexit1(SB)  // does not return    // traceback from goexit1 must hit code range of goexit    BYTE  $0x90  // NOP

根据之前的分析,非main goroutine返回时直接返回到goexit第二条指令(CALL runtime.goexit1(SB))处,该指令继续调用goexit1。

来看runtime/proc.go文件2652行代码:

// Finishes execution of the current goroutine.func goexit1() {
   
       if raceenabled {  //与竞争状态检查有关,不关注        racegoend()    }    if trace.enabled { //与backtrace有关,不关注        traceGoEnd()    }    mcall(goexit0)}

goexit1通过调用mcall从g2切换到g0,然后在g0栈上调用g0.goexit0。

来看runtime/asm_amd64.s文件279行代码:

# func mcall(fn func(*g))# Switch to m->g0's stack, call fn(g).# Fn must never return. It should gogo(&g->sched)# to keep running g.# mcall的参数是一个指向funcval对象的指针TEXT runtime·mcall(SB), NOSPLIT, $0-8    #取出参数的值放入DI寄存器,它是funcval对象的指针,此场景中fn.fn是goexit0的地址    MOVQ  fn+0(FP), DI    get_tls(CX)    MOVQ  g(CX), AX # AX = g,本场景g 是 g2    #mcall返回地址放入BX    MOVQ  0(SP), BX# caller's PC    #保存g2的调度信息,因为我们要从当前正在运行的g2切换到g0    MOVQ  BX, (g_sched+gobuf_pc)(AX)   #g.sched.pc = BX,保存g2的rip    LEAQ  fn+0(FP), BX # caller's SP      MOVQ  BX, (g_sched+gobuf_sp)(AX)  #g.sched.sp = BX,保存g2的rsp    MOVQ  AX, (g_sched+gobuf_g)(AX)   #g.sched.g = g    MOVQ  BP, (g_sched+gobuf_bp)(AX)  #g.sched.bp = BP,保存g2的rbp    # switch to m->g0 & its stack, call fn    #下面三条指令主要目的是找到g0的指针    MOVQ  g(CX), BX         #BX = g    MOVQ  g_m(BX), BX    #BX = g.m    MOVQ  m_g0(BX), SI   #SI = g.m.g0    #此刻,SI = g0, AX = g,所以这里在判断g 是否是 g0,如果g == g0则一定是哪里代码写错了    CMPQ  SI, AX# if g == m->g0 call badmcall    JNE  3(PC)    MOVQ  $runtime·badmcall(SB), AX    JMP  AX    #把g0的地址设置到线程本地存储之中    MOVQ  SI, g(CX)    #恢复g0的栈顶指针到CPU的rsp积存,这一条指令完成了栈的切换,从g的栈切换到了g0的栈    MOVQ  (g_sched+gobuf_sp)(SI), SP# rsp = g0->sched.sp    #AX = g    PUSHQ  AX   #fn的参数g入栈     MOVQ  DI, DX   #DI是结构体funcval实例对象的指针,它的第一个成员才是goexit0的地址    MOVQ  0(DI), DI   #读取第一个成员到DI寄存器    CALL  DI   #调用goexit0(g)    POPQ  AX    MOVQ  $runtime·badmcall2(SB), AX    JMP  AX    RET

mcall的参数是一个函数,Go函数变量不是直接指向函数代码的指针,而是一个指向funcval结构体对象的指针,func第一个成员fn才是指向函数代码的指针。

funcval的简单定义如下:

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

由此可知,当前场景mcall的参数fn中fn成员存放的才是goexit0第一条指令的地址。

mcall主要功能如下:

  1. 先从当前运行的g(当前场景是g2)切换到g0,包括保存当前g的调度信息,将g0设置到TLS中,修改CPU的rsp使其指向g0的栈。

  2. 以当前运行的g(当前场景是g2)为参数调用fn(当前场景为goexit0)。

可以看到,mcall跟gogo完全相反,gogo实现从g0切换到某个g中运行,mcall则是从某个g切换到g0运行,也正是如此,mcall的代码跟gogo非常相似。

但mcall跟gogo在做切换时有个重要区别,就是gogo从g0切换到其它g时首先切换了栈,然后通过跳转指令从runtime代码切换到用户goroutine代码,mcall在从其它g切换到g0时,则只切换了栈,并未使用跳转至指令跳转到runtime代码去执行。

出现上述区别的原因就在于从g0切换到其它goroutine之前执行的是runtime的代码,使用的是g0的栈,所以切换时要先切换栈,之后再从runtime代码跳转到某个g代码中去执行(切换栈和跳转指令的顺序不能颠倒,原因就是跳转后执行的就是用户goroutine代码了,没有机会再切换栈了),而从某个g切换到g0,g使用的是call调用mcall,mcall本身就是runtime代码,所以call本身已经完成了从g代码到runtime代码的跳转,因此mcall自身代码就不需要跳转,只需切换栈就可实现了。

从g2跳转至g0,之后在g0执行goexit0,该函数完成清理工作,流程如下:

  1. 将g状态从_Grunning修改为_Gdead。

  2. 将g的一些字段清空为0值。

  3. 调用dropg解除g和m之间的关联,本质就是设置g->m = nil, m->currg = nil。

  4. 将g放入p的freeg队列缓存起来供下次创建g时快速获取,而不用从内存分配。

  5. 调用schedule再次进行调度。

freeg就是goroutine的对象池。 

来看runtime/proc.go文件2662行代码:

// goexit continuation on g0.func goexit0(gp *g) {
   
       _g_ := getg()  //g0    casgstatus(gp, _Grunning, _Gdead) //g马上退出,所以设置其状态为_Gdead    if isSystemGoroutine(gp, false) {
   
           atomic.Xadd(&sched.ngsys, -1)    }      //清空g保存的一些信息    gp.m = nil    locked := gp.lockedm != 0    gp.lockedm = 0    _g_.m.lockedg = 0    gp.paniconfault = false    gp._defer = nil // should be true already but just in case.    gp._panic = nil // non-nil for Goexit during panic. points at stack-allocated data.    gp.writebuf = nil    gp.waitreason = 0    gp.param = nil    gp.labels = nil    gp.timer = nil    ......    // Note that gp's stack scan is now "valid" because it has no    // stack.    gp.gcscanvalid = true       //g->m = nil, m->currg = nil 解绑g和m之关系    dropg()    ......       gfput(_g_.m.p.ptr(), gp) //g放入p的freeg队列,方便下次重用,免得再去申请内存,提高效率    ......       //下面再次调用schedule    schedule()}

至此g的生命周期就结束了,工作线程再次调用schedule进入新一轮循环调度

任何goroutine调度起来运行都是经过schedule()->execute()->gogo()函数调用链完成的,且在此调用链的函数中是没有返回的。

以刚聊完的g2为例,g2从调度起来执行到结束走的是【schedule()->execute()->gogo()->g2()->goexit()->goexit1()->mcall()->goexit0()->schedule()】,这就可看出,一轮调度从调用schedule开始,经过一系列代码执行,最后再次调用schedule进行新一轮的调度,这一过程可称之为调度循环,这里聊的调度循环指的是在某一工作线程的调度循环,Go程序可能同时运行多个工作线程,每个工作线程都有,也正在运行属于自己的调度循环。

在复杂程序中,调度会进行很多次循环,也就是很多次函数调用,每调用一次函数就会消耗一定的栈空间,如果一直是没有返回的调用,那g0栈空间是否会耗尽呢?

其实不会的,每次执行mcall切换到g0都是切换到g0.sched.sp所指的固定位置,因为从schedule开始的一系列函数都不会返回,所以重新使用这些函数上一轮调度的栈内存也是可以的,来看下工作线程的执行流程和调度循环,如下:

工作线程的执行流程总结如下:

  1. 初始化,调用mstart。

  2. 调用mstart1,在mstart1中调用save设置g0.sched.sp和g0.sched.pc等调度信息,其中g0.sched.sp指向mstart栈帧的栈顶。

  3. 依次调用schedule->execute->gogo执行调度。

  4. 运行用户goroutine代码。

  5. 用户goroutine代码执行过程中调用某些runtime函数,然后这些函数调用g0.sched.sp所指的栈,最终再次调用schedule进入新一轮调度,之后工作线程一直循环走第3到第5步,直到程序退出。

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

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

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

 

猜你喜欢

转载自blog.csdn.net/luyaran/article/details/120947814