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

既然是調度,也就是說不是每個G都能一直處於運行狀態,等G不能運行時,就把它存起來,再調度下一個能運行的G過來運行。
暫時不能運行的G,P上會有個本地隊列去存放這些這些G,P的本地隊列存不下的話,還有個全局隊列,干的事情也類似。
了解這個背景后,再回到 goexit0 方法看看,做的事情就是將當前的協程G置為_Gdead狀態,然后把它從M上摘下來,嘗試放回到P的本地隊列中。然后重新調度一波,獲取另一個能跑的G,拿出來跑。

所以簡單總結一下,只要執行 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執行一半就退出協程,該怎么辦?你知道該怎么回答了吧?
好了,兄弟們,有沒有發現這篇文章寫的又水又短,真的是因為我變懶了嗎?
不!
當然不!
我是為了兄弟們的身體健康考慮,保持蹲姿太久對身體不好,懂?
如果文章對你有幫助,歡迎.....
算了。
一起在知識的海洋里嗆水吧
我是小白,我們下期見!
關注:【小白debug】
參考資料
饒大的《哪來里的 goexit?》- https://qcrao.com/2021/06/07/where-is-goexit-from/
