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


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


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

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

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

  • 調度循環。

非main goroutine會返回到goexit嗎

首先來看一段代碼:

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函數中!

至此,我們已經證實非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這一調度循環直到進程退出為止。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM