動(dòng)圖圖解!怎么讓goroutine跑一半就退出?

光看標(biāo)題,大家可能不太理解我說的是啥。
我們平時(shí)創(chuàng)建一個(gè)協(xié)程,跑一段邏輯,代碼大概長(zhǎng)這樣。
package mainimport ("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)}// 這段代碼,正常運(yùn)行會(huì)有下面的結(jié)果打印4打印1打印3打印2
注意這上面"打印2"是在defer中的,所以會(huì)在函數(shù)結(jié)束前打印。因此后置于"打印3"。
那么今天的問題是,如何讓Foo()函數(shù)跑一半就結(jié)束,比如說跑到打印2,就退出協(xié)程。輸出如下結(jié)果
打印4打印1打印2
也不賣關(guān)子了,我這邊直接說答案。
在"打印2"后面插入一個(gè)?runtime.Goexit(), 協(xié)程就會(huì)直接結(jié)束。并且結(jié)束前還能執(zhí)行到defer里的打印2。
package mainimport ("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)}// 輸出結(jié)果打印4打印1打印2
可以看到打印3這一行沒出現(xiàn)了,協(xié)程確實(shí)提前結(jié)束了。
其實(shí)面試題到這里就講完了,這一波自問自答可還行?
但這不是今天的重點(diǎn),我們需要搞搞清楚內(nèi)部的邏輯。
runtime.Goexit()是什么?
看一下內(nèi)部實(shí)現(xiàn)。
func Goexit() {// 以下函數(shù)省略一些邏輯...gp := getg()for {// 獲取defer并執(zhí)行d := gp._deferreflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz))}goexit1()}func goexit1() {mcall(goexit0)}
從代碼上看,runtime.Goexit()會(huì)先執(zhí)行一下defer里的方法,這里就解釋了開頭的代碼里為什么在defer里的打印2能正常輸出。
然后代碼再執(zhí)行goexit1。本質(zhì)就是對(duì)goexit0的簡(jiǎn)單封裝。
我們可以把代碼繼續(xù)跟下去,看看goexit0做了什么。
// goexit continuation on g0.func goexit0(gp *g) {// 獲取當(dāng)前的 goroutine_g_ := getg()// 將當(dāng)前goroutine的狀態(tài)置為 _Gdeadcasgstatus(gp, _Grunning, _Gdead)// 全局協(xié)程數(shù)減一if isSystemGoroutine(gp, false) {atomic.Xadd(&sched.ngsys, -1)}// 省略各種清空邏輯...// 把g從m上摘下來。dropg()// 把這個(gè)g放回到p的本地協(xié)程隊(duì)列里,放不下放全局協(xié)程隊(duì)列。gfput(_g_.m.p.ptr(), gp)// 重新調(diào)度,拿下一個(gè)可運(yùn)行的協(xié)程出來跑schedule()}
這段代碼,信息密度比較大。
很多名詞可能讓人一臉懵。
簡(jiǎn)單描述下,Go語言里有個(gè)GMP模型的說法,M是內(nèi)核線程,G也就是我們平時(shí)用的協(xié)程goroutine,P會(huì)在G和M之間做工具人,負(fù)責(zé)調(diào)度G到M上運(yùn)行。

既然是調(diào)度,也就是說不是每個(gè)G都能一直處于運(yùn)行狀態(tài),等G不能運(yùn)行時(shí),就把它存起來,再調(diào)度下一個(gè)能運(yùn)行的G過來運(yùn)行。
暫時(shí)不能運(yùn)行的G,P上會(huì)有個(gè)本地隊(duì)列去存放這些這些G,P的本地隊(duì)列存不下的話,還有個(gè)全局隊(duì)列,干的事情也類似。
了解這個(gè)背景后,再回到?goexit0?方法看看,做的事情就是將當(dāng)前的協(xié)程G置為_Gdead狀態(tài),然后把它從M上摘下來,嘗試放回到P的本地隊(duì)列中。然后重新調(diào)度一波,獲取另一個(gè)能跑的G,拿出來跑。

所以簡(jiǎn)單總結(jié)一下,只要執(zhí)行 goexit 這個(gè)函數(shù),當(dāng)前協(xié)程就會(huì)退出,同時(shí)還能調(diào)度下一個(gè)可執(zhí)行的協(xié)程出來跑。
看到這里,大家應(yīng)該就能理解,開頭的代碼里,為什么runtime.Goexit()能讓協(xié)程只執(zhí)行一半就結(jié)束了。
goexit的用途
看是看懂了,但是會(huì)忍不住疑惑。面試這么問問,那只能說明你遇到了一個(gè)喜歡為難年輕人的面試官,但正經(jīng)人誰會(huì)沒事跑一半?yún)f(xié)程就結(jié)束呢?所以goexit的真實(shí)用途是啥?
有個(gè)小細(xì)節(jié),不知道大家平時(shí)debug的時(shí)候有沒有關(guān)注過。

為了說明問題,這里先給出一段代碼。
package mainimport ("fmt""time")func Foo() {fmt.Println("打印1")}func main() {go Foo()fmt.Println("打印3")time.Sleep(1000*time.Second)}
這是一段非常簡(jiǎn)單的代碼,輸出什么完全不重要。通過go關(guān)鍵字啟動(dòng)了一個(gè)goroutine執(zhí)行Foo(),里面打印一下就結(jié)束,主協(xié)程sleep很長(zhǎng)時(shí)間,只為死等。
這里我們新啟動(dòng)的協(xié)程里,在Foo()函數(shù)內(nèi)隨便打個(gè)斷點(diǎn)。然后debug一下。

會(huì)發(fā)現(xiàn),這個(gè)協(xié)程的堆棧底部是從runtime.goexit()里開始啟動(dòng)的。
如果大家平時(shí)有注意觀察,會(huì)發(fā)現(xiàn),其實(shí)所有的堆棧底部,都是從這個(gè)函數(shù)開始的。我們繼續(xù)跟跟代碼。
goexit是什么?
從上面的debug堆棧里點(diǎn)進(jìn)去會(huì)發(fā)現(xiàn),這是個(gè)匯編函數(shù),可以看出調(diào)用的是runtime包內(nèi)的?goexit1()?函數(shù)。
// The top-most function running on a goroutine// returns to goexit+PCQuantum.TEXT runtime·goexit(SB),NOSPLIT,$0-0BYTE $0x90 // NOPCALL runtime·goexit1(SB) // does not return// traceback from goexit1 must hit code range of goexitBYTE $0x90 // NOP
于是跟到了pruntime/proc.go里的代碼中。
// 省略部分代碼func goexit1() {mcall(goexit0)}
是不是很熟悉,這不就是我們開頭講runtime.Goexit()里內(nèi)部執(zhí)行的goexit0嗎。
為什么每個(gè)堆棧底部都是這個(gè)方法?
我們首先需要知道的是,函數(shù)棧的執(zhí)行過程,是先進(jìn)后出。
假設(shè)我們有以下代碼
func main() {B()}func B() {A()}func A() {}
上面的代碼是main運(yùn)行B函數(shù),B函數(shù)再運(yùn)行A函數(shù),代碼執(zhí)行時(shí)就跟下面的動(dòng)圖那樣。

這個(gè)是先進(jìn)后出的過程,也就是我們常說的函數(shù)棧,執(zhí)行完子函數(shù)A()后,就會(huì)回到父函數(shù)B()中,執(zhí)行完B()后,最后就會(huì)回到main()。這里的棧底是main(),如果在棧底插入的是?goexit?的話,那么當(dāng)程序執(zhí)行結(jié)束的時(shí)候就都能跑到goexit里去。
結(jié)合前面講過的內(nèi)容,我們就能知道,此時(shí)棧底的goexit,會(huì)在協(xié)程內(nèi)的業(yè)務(wù)代碼跑完后被執(zhí)行到,從而實(shí)現(xiàn)協(xié)程退出,并調(diào)度下一個(gè)可執(zhí)行的G來運(yùn)行。
那么問題又來了,棧底插入goexit這件事是誰做的,什么時(shí)候做的?
直接說答案,這個(gè)在runtime/proc.go里有個(gè)newproc1方法,只要是創(chuàng)建協(xié)程都會(huì)用到這個(gè)方法。里面有個(gè)地方是這么寫的。
func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) {// 獲取當(dāng)前g_g_ := getg()// 獲取當(dāng)前g所在的p_p_ := _g_.m.p.ptr()// 創(chuàng)建一個(gè)新 goroutinenewg := gfget(_p_)// 底部插入goexitnewg.sched.pc = funcPC(goexit) + sys.PCQuantumnewg.sched.g = guintptr(unsafe.Pointer(newg))// 把新創(chuàng)建的g放到p中runqput(_p_, newg, true)// ...}
主要的邏輯是獲取當(dāng)前協(xié)程G所在的調(diào)度器P,然后創(chuàng)建一個(gè)新G,并在棧底插入一個(gè)goexit。
所以我們每次debug的時(shí)候,就都能看到函數(shù)棧底部有個(gè)goexit函數(shù)。
main函數(shù)也是個(gè)協(xié)程,棧底也是goexit?
關(guān)于main函數(shù)棧底是不是也有個(gè)goexit,我們對(duì)下面代碼斷點(diǎn)看下。直接得出結(jié)果。

main函數(shù)棧底也是goexit()。
從?asm_amd64.s可以看到Go程序啟動(dòng)的流程,這里提到的?runtime·mainPC?其實(shí)就是?runtime.main.
// create a new goroutine to start programMOVQ $runtime·mainPC(SB), AX // 也就是runtime.mainPUSHQ AXPUSHQ $0 // arg sizeCALL runtime·newproc(SB)
通過runtime·newproc創(chuàng)建runtime.main協(xié)程,然后在runtime.main里會(huì)啟動(dòng)main.main函數(shù),這個(gè)就是我們平時(shí)寫的那個(gè)main函數(shù)了。
// runtime/proc.gofunc main() {// 省略大量代碼fn := main_main // 其實(shí)就是我們的main函數(shù)入口fn()}//go:linkname main_main main.mainfunc main_main()
結(jié)論是,其實(shí)main函數(shù)也是由newproc創(chuàng)建的,只要通過newproc創(chuàng)建的goroutine,棧底就會(huì)有一個(gè)goexit。
os.Exit()和runtime.Goexit()有什么區(qū)別
最后再回到開頭的問題,實(shí)現(xiàn)一下首尾呼應(yīng)。
開頭的面試題,除了runtime.Goexit(),是不是還可以改為用os.Exit()?
同樣都是帶有"退出"的含義,兩者退出的對(duì)象不同。os.Exit()?指的是整個(gè)進(jìn)程退出;而runtime.Goexit()指的是協(xié)程退出。
可想而知,改用os.Exit()?這種情況下,defer里的內(nèi)容就不會(huì)被執(zhí)行到了。
package mainimport ("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)}// 輸出結(jié)果打印4打印1
總結(jié)
?通過?runtime.Goexit()可以做到提前結(jié)束協(xié)程,且結(jié)束前還能執(zhí)行到defer的內(nèi)容??runtime.Goexit()其實(shí)是對(duì)goexit0的封裝,只要執(zhí)行 goexit0 這個(gè)函數(shù),當(dāng)前協(xié)程就會(huì)退出,同時(shí)還能調(diào)度下一個(gè)可執(zhí)行的協(xié)程出來跑。?通過newproc可以創(chuàng)建出新的goroutine,它會(huì)在函數(shù)棧底部插入一個(gè)goexit。?os.Exit()?指的是整個(gè)進(jìn)程退出;而runtime.Goexit()指的是協(xié)程退出。兩者含義有區(qū)別。
最后
無用的知識(shí)又增加了。
一般情況下,業(yè)務(wù)開發(fā)中,誰會(huì)沒事執(zhí)行這個(gè)函數(shù)呢?
但是開發(fā)中不關(guān)心,不代表面試官不關(guān)心!
下次面試官問你,如果想在goroutine執(zhí)行一半就退出協(xié)程,該怎么辦?你知道該怎么回答了吧?
好了,兄弟們,有沒有發(fā)現(xiàn)這篇文章寫的又水又短,真的是因?yàn)槲易儜辛藛幔?/p>
不!
當(dāng)然不!
我是為了兄弟們的身體健康考慮,保持蹲姿太久對(duì)身體不好,懂?
如果文章對(duì)你有幫助,歡迎.....
算了。
一起在知識(shí)的海洋里嗆水吧
我是小白,我們下期見!
點(diǎn)擊下方名片,關(guān)注公眾號(hào):【小白debug】
不滿足于在留言區(qū)說騷話?
加我,我們建了個(gè)劃水吹牛皮群,在群里,你可以跟你下次跳槽可能遇到的同事或面試官聊點(diǎn)有趣的話題。就超!開!心!
文章推薦:
?程序員防猝死指南?TCP粘包 數(shù)據(jù)包:我只是犯了每個(gè)數(shù)據(jù)包都會(huì)犯的錯(cuò) |硬核圖解?動(dòng)圖圖解!既然IP層會(huì)分片,為什么TCP層也還要分段?
參考資料
饒大的《哪來里的 goexit?》- https://qcrao.com/2021/06/07/where-is-goexit-from/
