本文參與了思否技術徵文,歡迎正在閱讀的你也加入。
光看標題,大家可能不太理解我說的是啥。
我們平時建立一個協程,跑一段邏輯,程式碼大概長這樣。
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執行一半就退出協程,該怎麼辦?你知道該怎麼回答了吧?
好了,兄弟們,有沒有發現這篇文章寫的又水又短,真的是因為我變懶了嗎?
不!
當然不!
我是為了兄弟們的身體健康考慮,保持蹲姿太久對身體不好,懂?
如果文章對你有幫助,歡迎.....
算了。
一起在知識的海洋裡嗆水吧
我是小白,我們下期見!
參考資料
饒大的《哪來裡的 goexit?》- https://qcrao.com/2021/06/07/...