動圖圖解!怎麼讓goroutine跑一半就退出?

小白debug發表於2021-11-21

本文參與了思否技術徵文,歡迎正在閱讀的你也加入。

光看標題,大家可能不太理解我說的是啥。

我們平時建立一個協程,跑一段邏輯,程式碼大概長這樣。

package main

import (
    "fmt"
    "time"
)
func Foo() {
    fmt.Println("列印1")
    defer fmt.Println("列印2")
    fmt.Println("列印3")
}

func main() {
    go  Foo()
    fmt.Println("列印4")
    time.Sleep(1000*time.Second)
}

// 這段程式碼,正常執行會有下面的結果
列印4
列印1
列印3
列印2

注意這上面"列印2"是在defer中的,所以會在函式結束前列印。因此後置於"列印3"。

那麼今天的問題是,如何讓Foo()函式跑一半就結束,比如說跑到列印2,就退出協程。輸出如下結果

列印4
列印1
列印2

也不賣關子了,我這邊直接說答案。

在"列印2"後面插入一個 runtime.Goexit(), 協程就會直接結束。並且結束前還能執行到defer裡的列印2

package main

import (
    "fmt"
    "runtime"
    "time"
)
func Foo() {
    fmt.Println("列印1")
    defer fmt.Println("列印2")
    runtime.Goexit() // 加入這行
    fmt.Println("列印3")
}

func main() {
    go  Foo()
    fmt.Println("列印4")
    time.Sleep(1000*time.Second)
}


// 輸出結果
列印4
列印1
列印2

可以看到列印3這一行沒出現了,協程確實提前結束了。

其實面試題到這裡就講完了,這一波自問自答可還行?

但這不是今天的重點,我們需要搞搞清楚內部的邏輯。

runtime.Goexit()是什麼?

看一下內部實現。

func Goexit() {
    // 以下函式省略一些邏輯...
    gp := getg() 
    for {
    // 獲取defer並執行
        d := gp._defer
        reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz))
    }
    goexit1()
}

func goexit1() {
    mcall(goexit0)
}

從程式碼上看,runtime.Goexit()會先執行一下defer裡的方法,這裡就解釋了開頭的程式碼裡為什麼在defer裡的列印2能正常輸出。

然後程式碼再執行goexit1。本質就是對goexit0的簡單封裝。

我們可以把程式碼繼續跟下去,看看goexit0做了什麼。

// goexit continuation on g0.
func goexit0(gp *g) {
  // 獲取當前的 goroutine
    _g_ := getg()
    // 將當前goroutine的狀態置為 _Gdead
    casgstatus(gp, _Grunning, _Gdead)
  // 全域性協程數減一
    if isSystemGoroutine(gp, false) {
        atomic.Xadd(&sched.ngsys, -1)
    }
  
  // 省略各種清空邏輯...

  // 把g從m上摘下來。
  dropg()


    // 把這個g放回到p的本地協程佇列裡,放不下放全域性協程佇列。
    gfput(_g_.m.p.ptr(), gp)

  // 重新排程,拿下一個可執行的協程出來跑
    schedule()
}

這段程式碼,資訊密度比較大。

很多名詞可能讓人一臉懵。

簡單描述下,Go語言裡有個GMP模型的說法,M是核心執行緒,G也就是我們平時用的協程goroutineP會在G和M之間做工具人,負責排程GM上執行。

GMP圖

既然是排程,也就是說不是每個G都能一直處於執行狀態,等G不能執行時,就把它存起來,再排程下一個能執行的G過來執行。

暫時不能執行的G,P上會有個本地佇列去存放這些這些G,P的本地佇列存不下的話,還有個全域性佇列,乾的事情也類似。

瞭解這個背景後,再回到 goexit0 方法看看,做的事情就是將當前的協程G置為_Gdead狀態,然後把它從M上摘下來,嘗試放回到P的本地佇列中。然後重新排程一波,獲取另一個能跑的G,拿出來跑。

goexit

所以簡單總結一下,只要執行 goexit 這個函式,當前協程就會退出,同時還能排程下一個可執行的協程出來跑。

看到這裡,大家應該就能理解,開頭的程式碼裡,為什麼runtime.Goexit()能讓協程只執行一半就結束了。

goexit的用途

看是看懂了,但是會忍不住疑惑。面試這麼問問,那隻能說明你遇到了一個喜歡為難年輕人的面試官,但正經人誰會沒事跑一半協程就結束呢?所以goexit真實用途是啥?

有個小細節,不知道大家平時debug的時候有沒有關注過。

為了說明問題,這裡先給出一段程式碼。

package main

import (
    "fmt"
    "time"
)
func Foo() {
    fmt.Println("列印1")
}

func main() {
    go  Foo()
    fmt.Println("列印3")
    time.Sleep(1000*time.Second)
}

這是一段非常簡單的程式碼,輸出什麼完全不重要。透過go關鍵字啟動了一個goroutine執行Foo(),裡面列印一下就結束,主協程sleep很長時間,只為死等

這裡我們新啟動的協程裡,在Foo()函式內隨便打個斷點。然後debug一下。

會發現,這個協程的堆疊底部是從runtime.goexit()裡開始啟動的。

如果大家平時有注意觀察,會發現,其實所有的堆疊底部,都是從這個函式開始的。我們繼續跟跟程式碼。

goexit是什麼?

從上面的debug堆疊裡點進去會發現,這是個彙編函式,可以看出呼叫的是runtime包內的 goexit1() 函式。

// 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

於是跟到了pruntime/proc.go裡的程式碼中。

// 省略部分程式碼
func goexit1() {
    mcall(goexit0)
}

是不是很熟悉,這不就是我們開頭講runtime.Goexit()裡內部執行的goexit0嗎。

為什麼每個堆疊底部都是這個方法?

我們首先需要知道的是,函式棧的執行過程,是先進後出。

假設我們有以下程式碼

func main() {
    B()
}

func B() {
    A()
}

func A() {

}

上面的程式碼是main執行B函式,B函式再執行A函式,程式碼執行時就跟下面的動圖那樣。

函式堆疊執行順序

這個是先進後出的過程,也就是我們常說的函式棧,執行完子函式A()後,就會回到父函式B()中,執行完B()後,最後就會回到main()。這裡的棧底是main(),如果在棧底插入的是 goexit 的話,那麼當程式執行結束的時候就都能跑到goexit裡去。

結合前面講過的內容,我們就能知道,此時棧底的goexit,會在協程內的業務程式碼跑完後被執行到,從而實現協程退出,並排程下一個可執行的G來執行。


那麼問題又來了,棧底插入goexit這件事是誰做的,什麼時候做的?

直接說答案,這個在runtime/proc.go裡有個newproc1方法,只要是建立協程都會用到這個方法。裡面有個地方是這麼寫的。

func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) {
    // 獲取當前g
  _g_ := getg()
    // 獲取當前g所在的p
    _p_ := _g_.m.p.ptr()
  // 建立一個新 goroutine
    newg := gfget(_p_)

    // 底部插入goexit
    newg.sched.pc = funcPC(goexit) + sys.PCQuantum 
    newg.sched.g = guintptr(unsafe.Pointer(newg))
    // 把新建立的g放到p中
    runqput(_p_, newg, true)

    // ...
}

主要的邏輯是獲取當前協程G所在的排程器P,然後建立一個新G,並在棧底插入一個goexit。

所以我們每次debug的時候,就都能看到函式棧底部有個goexit函式。

main函式也是個協程,棧底也是goexit?

關於main函式棧底是不是也有個goexit,我們對下面程式碼斷點看下。直接得出結果。

main函式棧底也是goexit()

asm_amd64.s可以看到Go程式啟動的流程,這裡提到的 runtime·mainPC 其實就是 runtime.main.

    // create a new goroutine to start program
    MOVQ    $runtime·mainPC(SB), AX        // 也就是runtime.main
    PUSHQ    AX
    PUSHQ    $0            // arg size
    CALL    runtime·newproc(SB)

透過runtime·newproc建立runtime.main協程,然後在runtime.main裡會啟動main.main函式,這個就是我們平時寫的那個main函式了。

// runtime/proc.go
func main() {
    // 省略大量程式碼
    fn := main_main // 其實就是我們的main函式入口
    fn() 
}

//go:linkname main_main main.main
func main_main()

結論是,其實main函式也是由newproc建立的,只要透過newproc建立的goroutine,棧底就會有一個goexit。

os.Exit()和runtime.Goexit()有什麼區別

最後再回到開頭的問題,實現一下首尾呼應。

開頭的面試題,除了runtime.Goexit(),是不是還可以改為用os.Exit()

同樣都是帶有"退出"的含義,兩者退出的物件不同。os.Exit() 指的是整個程式退出;而runtime.Goexit()指的是協程退出。

可想而知,改用os.Exit() 這種情況下,defer裡的內容就不會被執行到了。

package main

import (
    "fmt"
    "os"
    "time"
)
func Foo() {
    fmt.Println("列印1")
    defer fmt.Println("列印2")
    os.Exit(0)
    fmt.Println("列印3")
}

func main() {
    go  Foo()
    fmt.Println("列印4")
    time.Sleep(1000*time.Second)
}

// 輸出結果
列印4
列印1

總結

  • 透過 runtime.Goexit()可以做到提前結束協程,且結束前還能執行到defer的內容
  • runtime.Goexit()其實是對goexit0的封裝,只要執行 goexit0 這個函式,當前協程就會退出,同時還能排程下一個可執行的協程出來跑。
  • 透過newproc可以建立出新的goroutine,它會在函式棧底部插入一個goexit。
  • os.Exit() 指的是整個程式退出;而runtime.Goexit()指的是協程退出。兩者含義有區別。

最後

無用的知識又增加了。

一般情況下,業務開發中,誰會沒事執行這個函式呢?

但是開發中不關心,不代表面試官不關心!

下次面試官問你,如果想在goroutine執行一半就退出協程,該怎麼辦?你知道該怎麼回答了吧?


好了,兄弟們,有沒有發現這篇文章寫的又水又短,真的是因為我變懶了嗎?

不!

當然不!

我是為了兄弟們的身體健康考慮,保持蹲姿太久對身體不好,懂?


如果文章對你有幫助,歡迎.....

算了。

一起在知識的海洋裡嗆水吧

我是小白,我們下期見!

參考資料

饒大的《哪來裡的 goexit?》- https://qcrao.com/2021/06/07/...

相關文章