天天看點

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

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

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

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

也就是我們平時用的協程

goroutine

P

會在

G和M之間

做工具人,負責排程

G

M

上運作。

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

GMP圖

既然是排程,也就是說不是每個

G

都能一直處于運作狀态,等G不能運作時,就把它存起來,再排程下一個能運作的G過來運作。

暫時不能運作的G,P上會有個本地隊列去存放這些這些G,P的本地隊列存不下的話,還有個全局隊列,幹的事情也類似。

了解這個背景後,再回到

goexit0

方法看看,做的事情就是将目前的協程G置為

_Gdead

狀态,然後把它從M上摘下來,嘗試放回到P的本地隊列中。然後重新排程一波,擷取另一個能跑的G,拿出來跑。

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

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

一下。

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

會發現,這個協程的堆棧底部是從

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函數,代碼執行時就跟下面的動圖那樣。

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

函數堆棧執行順序

這個是先進後出的過程,也就是我們常說的函數棧,執行完子函數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

,我們對下面代碼斷點看下。直接得出結果。

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

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.gofunc main() {    // 省略大量代碼    fn := main_main // 其實就是我們的main函數入口    fn() } //go:linkname main_main main.mainfunc 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/where-is-goexit-from/