動圖圖解!怎麼讓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也就是我們平時用的協程goroutineP會在G和M之間做工具人,負責調度GM上運行。

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.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?》- //qcrao.com/2021/06/07/where-is-goexit-from/