非main goroutine的退出及調度循環(15)

本文是《Go語言調度器源代碼情景分析》系列的第15篇,也是第二章的第5小節。緩存


上一節咱們說過main goroutine退出時會直接執行exit系統調用退出整個進程,而非main goroutine退出時則會進入goexit函數完成最後的清理工做,本小節咱們首先就來驗證一下非main goroutine執行完成後是否真的會去執行goexit,而後再對非main goroutine的退出流程作個梳理。這一節咱們須要重點理解如下內容:sass

  • 非main goroutine是如何返回到goexit函數的;函數

  • mcall函數如何從用戶goroutine切換到g0繼續執行;ui

  • 調度循環。atom

非main goroutine會返回到goexit嗎spa

首先來看一段代碼:線程

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,下面咱們就用這個g2的退出來驗證一下非main goroutine退出時是否真的會返回到goexit繼續執行。指針

怎麼驗證呢?比較簡單的辦法就是用gdb來調試,在gdb中首先使用backtrace命令查看g2函數是被誰調用的,而後單步執行看它可否返回到goexit繼續執行。下面是gdb調試過程:調試

(gdb) b main.g2       // 在main.g2函數入口處下斷點
Breakpoint1at0x4869c0:file/home/bobo/study/go/goexit.go, line 7.
(gdb) r
Startingprogram:/home/bobo/study/go/goexit 
Thread1"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處下斷點
Dumpofassemblercodeforfunctionmain.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>
Endofassemblerdump.
(gdb) b *0x0000000000486a0c             //在retq指令位置下斷點
Breakpoint2at0x486a0c:file/home/bobo/study/go/goexit.go, line 9.
(gdb) c
Continuing.

Thread1"goexit"hit Breakpoint 2 at /home/bobo/study/go/goexit.go:9
(gdb) disass             //程序停在了ret指令處
Dumpofassemblercodeforfunctionmain.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>
Endofassemblerdump.
(gdb) si        //單步執行一條指令
runtime.goexit () at /usr/local/go/src/runtime/asm_amd64.s:1338
1338CALLruntime·goexit1(SB)// does not return
(gdb) disass           //能夠看出來g2已經返回到了goexit函數中
Dumpofassemblercodeforfunctionruntime.goexit:
  0x0000000000450ad0<+0>:nop
=> 0x0000000000450ad1 <+1>:callq 0x42faf0 <runtime.goexit1>
  0x0000000000450ad6<+6>:nop

使用gdb調試時,首先咱們在g2函數入口處下了一個斷點,程序暫停後經過查看函數調用棧發現g2函數確實是被goexit調用的,而後再一次使用斷點讓程序暫停在g2返回以前的最後一條指令retq處,最後單步執行這條指令,能夠看到程序從g2函數返回到了goexit函數的第二條指令的位置,這個位置正是當初在建立goroutine時設置好的返回地址。能夠看到,雖然g2函數並非被goexit函數直接調用的,但它執行完成以後卻返回到了goexit函數中!rest

至此,咱們已經證明非main goroutine退出時確實會返回到goexit函數繼續執行,下面咱們就沿着這條線繼續分析非main goroutine的退出流程。

非main goroutine的退出流程

首先來看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 goroutine切換到g0,而後在g0棧上調用和執行goexit0這個函數。

runtime/asm_amd64.s : 270

# 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結構體對象的指針,funcval結構體對象的第一個成員fn纔是真正指向函數代碼的指針。

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的功能咱們能夠看出,mcall作的事情跟gogo函數徹底相反,gogo函數實現了從g0切換到某個goroutine去運行,而mcall實現了從某個goroutine切換到g0來運行,所以,mcall和gogo的代碼很是類似,然而mcall和gogo在作切換時有個重要的區別:gogo函數在從g0切換到其它goroutine時首先切換了棧,而後經過跳轉指令從runtime代碼切換到了用戶goroutine的代碼,而mcall函數在從其它goroutine切換回g0時只切換了棧,並未使用跳轉指令跳轉到runtime代碼去執行。爲何會有這個差異呢?緣由在於在從g0切換到其它goroutine以前執行的是runtime的代碼並且使用的是g0棧,因此切換時須要首先切換棧而後再從runtime代碼跳轉某個goroutine的代碼去執行(切換棧和跳轉指令不能顛倒,由於跳轉以後執行的就是用戶的goroutine代碼了,沒有機會切換棧了),然而從某個goroutine切換回g0時,goroutine使用的是call指令來調用mcall函數,mcall函數自己就是runtime的代碼,因此call指令其實已經完成了從goroutine代碼到runtime代碼的跳轉,所以mcall函數自身的代碼就不須要再跳轉了,只須要把棧切換到g0棧便可。

由於mcall跟gogo很是類似,前面咱們對gogo的每一條指令已經作過詳細的分析,因此這裏就再也不詳細解釋mcall的每一條指令了,但筆者在上面所展現的mcall代碼中作了一些註釋(註釋中的g表示當前正在運行的goroutine,咱們這個場景g就是g2),這裏你們能夠結合gogo的代碼以及mcall的代碼和註釋來加深對g0與其它goroutine之間的切換的理解。

從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時快速獲取而不用從內存分配。freeg就是g的一個對象池;

  5. 調用schedule函數再次進行調度;

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()
}

 

到此爲止g2的生命週期就結束了,工做線程再次調用了schedule函數進入新一輪的調度循環。

調度循環

咱們說過,任何goroutine被調度起來運行都是經過schedule()->execute()->gogo()這個函數調用鏈完成的,並且這個調用鏈中的函數一直沒有返回。以咱們剛剛討論過的g2 goroutine爲例,從g2開始被調度起來運行到退出是沿着下面這條路徑進行的

schedule()->execute()->gogo()->g2()->goexit()->goexit1()->mcall()->goexit0()->schedule()

能夠看出,一輪調度是從調用schedule函數開始的,而後通過一系列代碼的執行到最後又再次經過調用schedule函數來進行新一輪的調度,從一輪調度到新一輪調度的這一過程咱們稱之爲一個調度循環,這裏說的調度循環是指某一個工做線程的調度循環,而同一個Go程序中可能存在多個工做線程,每一個工做線程都有本身的調度循環,也就是說每一個工做線程都在進行着本身的調度循環。

從前面的代碼分析能夠得知,上面調度循環中的每個函數調用都沒有返回,雖然g2()->goexit()->goexit1()->mcall()這幾個函數是在g2的棧空間執行的,但剩下的函數都是在g0的棧空間執行的,那麼問題就來了,在一個複雜的程序中,調度可能會進行無數次循環,也就是說會進行無數次沒有返回的函數調用,你們都知道,每調用一次函數都會消耗必定的棧空間,而若是一直這樣無返回的調用下去不管g0有多少棧空間終究是會耗盡的,那麼這裏是否是有問題?其實沒有問題,關鍵點就在於,每次執行mcall切換到g0棧時都是切換到g0.sched.sp所指的固定位置,這之因此行得通,正是由於從schedule函數開始以後的一系列函數永遠都不會返回,因此重用這些函數上一輪調度時所使用過的棧內存是沒有問題的。

每一個工做線程的執行流程和調度循環都同樣,以下圖所示:

 

總結

咱們用上圖來總結一下工做線程的執行流程:

  1. 初始化,調用mstart函數;

  2. 調用mstart1函數,在該函數中調用save函數設置g0.sched.sp和g0.sched.pc等調度信息,其中g0.sched.sp指向mstart函數棧幀的棧頂;

  3. 依次調用schedule->execute->gogo函數執行調度;

  4. 運行用戶的goroutine代碼;

  5. 用戶goroutine代碼執行過程當中調用runtime中的某些函數,而後這些函數調用mcall切換到g0.sched.sp所指的棧並最終再次調用schedule函數進入新一輪調度,以後工做線程一直循環執行着3~5這一調度循環直到進程退出爲止。

相關文章
相關標籤/搜索