光看标題,大家可能不太了解我說的是啥。
我們平時建立一個協程,跑一段邏輯,代碼大概長這樣。
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
上運作。

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