Go語言的 defer 鏈如何被遍歷執行?

去年開始寫文章的第一篇就是關於 defer,名字比較文藝:《Golang 之輕鬆化解 defer 的溫柔陷阱》,還被吐槽了。因為這篇文章,到《Go 夜讀》講了一期。不過當時純粹是應用層面的,也還沒有跳進 Go 源碼這個大坑,文章看著比較清新,也沒有大段的源碼解析。

自從聽了曹大在《Go 夜讀》分享的 Go 彙編,以及研讀了阿波張的 Go 調度器源碼分析的文章後,各種源碼、彙編滿天飛……

上次歐神寫了一篇《Go GC 20 問》,全文也沒有一行源碼,整體讀下來很暢快。今天這篇也來嘗試一下這種寫法,不過,我們先從一個小的主題開始:defer 鏈表是如何被遍歷並執行的。

關於 defer 的源碼分析文章,網絡上也有很多。不過,很少有能完全說明白這個話題的,除了阿波張的。

我們知道,為了在退出函數前執行一些資源清理的操作,例如關閉文件、釋放連接等。會在函數里寫上多個 defer 語句,被 defered 的函數,以“先進後出”的順序,在 RET 指令前得以執行。

在一條函數調用鏈中,多個函數中會出現多個 defer 語句。例如:a() -> b() -> c() 中,每個函數里都有 defer 語句,而這些 defer 語句會創建對應個數的 _defer 結構體,這些結構體以鏈表的形式掛在 goroutine 結構體下。看起來像這樣:

Go語言的 defer 鏈如何被遍歷執行?

在編譯器的加持下,defer 語句會先調用 deferporc 函數,new 一個 _defer 結構體,掛到 g 上。當然,這裡的 new 會優先從當前綁定的 P 的 defer pool 裡取,沒取到會去全局的 defer pool 裡取,實在沒有的話就新建一個,很熟悉的套路。

這樣做好之後,等待函數體執行完,在 RET 指令之前(注意不是 return 之前),調用 deferreturn 函數完成 _defer 鏈表的遍歷,執行完這條鏈上所有被 defered 的函數(如關閉文件、釋放連接等)。這裡的問題是在 deferreturn 函數的最後,會使用 jmpdefer 跳轉到之前被 defered 的函數,這時控制權轉移到了用戶自定義的函數。這只是執行了一個被 defered 的函數,這條鏈上其他的被 defered 的函數,該如何得到執行呢?

答案就是控制權會再次交給 runtime,並再次執行 deferreturn 函數,完成 defer 鏈表的遍歷。那這一切是如何完成的呢?

這就要從 Go 彙編的棧幀說起了。先看一個彙編函數的聲明:

<code>TEXT runtime·gogo(SB), NOSPLIT, $16-8
/<code>

最後兩個數字表示 gogo 函數的棧幀大小為 16B,即函數的局部變量和為調用子函數準備的參數和返回值需要 16B 的棧空間;參數和返回值的大小加起來是 8B。實際上 gogo 函數的聲明是這樣的:

<code>// func gogo(buf *gobuf)
/<code>

參數及返回值的大小是給調用者“看”的,調用者根據這個數字可以構造棧:準備好被調函數需要的參數及返回值。

典型的函數調用場景下參數佈局圖如下圖:

Go語言的 defer 鏈如何被遍歷執行?

左圖中,主調函數準備好調用子函數的參數及返回值,執行 CALL 指令,將返回地址壓入棧頂,相當於執行了 PUSH IP,之後,將 BP 寄存器的值入棧,相當於執行了 PUSH BP,再 jmp 到被調函數。

圖中 return address 表示子函數執行完畢後,返回到上層函數中調用子函數語句的下一條要執行的指令,它屬於 caller 的棧幀。而調用者的 BP 則屬於被調函數的棧幀。

子函數執行完畢後,執行 RET 指令:首先將子函數棧底部的值賦到 CPU 的 BP 寄存器中,於是 BP 指向上層函數的 BP;再將 return address 賦到 IP 寄存器中,這時 SP 回到左圖所示的位置。相當於還原了整個調用子函數的現場,像是一切都沒發生過;接著,CPU 繼續執行 IP 寄存器裡的下一條指令。

再回到 defer 上來,其實在構造 _defer 結構體的時候,需要將當前函數的 SP、被 defered 的函數指針保存到 _defer 結構體中。並且會將被 defered 的函數所需要的參數 copy 到 _defer 結構體相鄰的位置。最終在調用被 defered 的函數的時候,用的就是這時被 copy 的值,相當於使用了它的一個快照,如果此參數不是指針或引用類型的話,會產生一些意料之外的 bug。

最後,在 deferreturn 函數里,這些被 defered 的函數得以執行,_defer 鏈表也會被逐漸“消耗”完。

使用一個阿波張文章中的例子:

<code>package main

import "fmt"

func sum(a, b int) {
c := a + b
fmt.Println("sum:" , c)
}

func f(a, b int) {
defer sum(a, b)

fmt.Printf("a: %d, b: %d\\n", a, b)
}

func main() {
a, b := 1, 2
f(a, b)
}
/<code>

執行完 f 函數時,最終會進入 deferreturn 函數:

<code>func deferreturn(arg0 uintptr) {
gp := getg()
\td := gp._defer
\tif d == nil {
\t\treturn
\t}
\t
\t......
\t
\tswitch d.siz {
\tcase 0:
\t\t// Do nothing.
\tcase sys.PtrSize:
\t\t*(*uintptr)(unsafe.Pointer(&arg0)) = *(*uintptr)(deferArgs(d))
\tdefault:
\t\tmemmove(unsafe.Pointer(&arg0), deferArgs(d), uintptr(d.siz)) // 移動參數
\t}
\tfn := d.fn
\td.fn = nil
\tgp._defer = d.link
\tfreedefer(d)

\t
\t_ = fn.fn
\tjmpdefer(fn, uintptr(unsafe.Pointer(&arg0)))
}
/<code>

免不了還是要看一下代碼,不然的話很難講清楚。

因為我們是在遍歷 _defer 鏈表,所以得有一個終止的條件:

<code>d := gp._defer
if d == nil {
\t\treturn
}
/<code>

也就是當 _defer 鏈表為空的時候,終止遍歷。在後面的代碼裡會看到,每執行完一個被 defered 的函數後,都會將 _defer 結構體從鏈表中刪除並回收,所以 _defer 鏈表會越來越短。

switch 語句裡要做的就是準備好被 defered 的函數(例子中就是 sum 函數)所需要的 a,b 兩個 int 型參數。參數從哪來呢?從 _defer 結構體相鄰的位置,還記得嗎,這是在 deferproc 函數里 copy 過去的。deferArgs(d) 返回的就是當時 copy 的目的地址。那現在要拷貝到哪去呢?答案是:unsafe.Pointer(&arg0)。我們知道,arg0 是 deferreturn 函數的參數,我們又知道,在 Go 彙編中,一個函數的參數是由它的主調函數準備的。因此 arg0 的地址實際上就是它的上層函數(在這裡就是 f 函數)的棧上放參數的位置。

函數的最後,通過 jmpdefer 跳轉到被 defered 的 sum 函數:

<code>jmpdefer(fn, uintptr(unsafe.Pointer(&arg0)))
/<code>

核心在於 jmpdefer 所做的事:

<code>TEXT runtime·jmpdefer(SB), NOSPLIT, $0-16
MOVQ\tfv+0(FP), DX\t// fn // defer 的函數的地址
MOVQ\targp+8(FP), BX
LEAQ\t-8(BX), SP\t// caller sp after CALL
MOVQ\t-8(SP), BP\t// restore BP as if deferreturn returned (harmless if framepointers not in use)
SUBQ\t$5, (SP)\t// return to CALL again
MOVQ\t0(DX), BX
JMP\tBX\t// but first run the deferred function
/<code>

首先將 sum 函數的地址放到 DX 寄存器中,最後通過 JMP 指令去執行。

<code>MOVQ\targp+8(FP), BX
LEAQ\t-8(BX), SP\t// caller sp after CALL // 執行 CALL 指令後 f 函數的棧頂
/<code>

這兩行實際上是調整了下當前 SP 寄存器的值,因為 argp+8(FP) 實際上是 jmpdefer 的第二個參數(它在 deferreturn 函數中),它指向 f 函數棧幀中的剛被 copy 過來的 sum 函數的參數。而 -8(BX) 就代表了 f 函數調用 deferreturn 的返回地址,實際上就是 deferreturn 函數的下一條指令地址。

接著,MOVQ -8(SP), BP 這條指令則重置了 BP 寄存器,使它指向了 f 棧幀 的 BP。這樣,SP、BP 寄存器回到了 f 函數調用 deferreturn 之前的狀態:f 剛準備好調用 deferreturn 的參數,並且把返回值壓棧了。相當於拋棄了 deferreturn 函數的棧幀,不過,確實也沒什麼用了。

接著 SUBQ $5, (SP) 把返回地址減少了 5B,剛好是一個 CALL 指令的長度。什麼意思?當執行完 deferreturn 函數之後,執行流程會返回到 CALL deferreturn 的下一條指令,將這個值減少 5B,也就又回到了 CALL deferreturn 指令,從而實現了“遞歸地”調用 deferreturn 函數的效果。當然,棧卻不會在增長!

Go語言的 defer 鏈如何被遍歷執行?

jmpdefer 函數的最後會執行 sum 函數,看起來就像是 f 函數親自調用 sum 函數一樣,參數、返回值都是就緒的。

等到 sum 函數執行完,執行流程就會跳轉到 call deferreturn 指令處重新進入 deferreturn 函數,遍歷完所有的 _defer 結構體,執行完所有的被 defered 的函數,才真正執行完 deferretrun 函數。

Go語言的 defer 鏈如何被遍歷執行?

到這裡,全文就結束了。我們可以看到,實現遍歷 defer 鏈表的關鍵就是 jmpdefer 函數所做的一些“見不得人”的工作,將調用 deferreturn 函數的返回地址減少了 5 個字節,使得被 defered 的函數執行完後,又回到 CALL deferreturn 指令處,從而實現“遞歸地”調用 deferreturn 函數,完成 _defer 鏈表的遍歷。

【阿波張 defer 源碼分析】https://mp.weixin.qq.com/s/iEtMbRXW4yYyCG0TTW5y9g

【阿波張 panic&recover】https://mp.weixin.qq.com/s/0JTBGHr-bV4ikLva-8ghEw

【阿波張 defer 基礎】https://mp.weixin.qq.com/s/QmeQTONUuWlr_sRNP8b5Tw

【彙編分析】https://segmentfault.com/a/1190000019804120?utm_medium=referral&utm_source=tuicool

【曹大 Go 彙編分享】https://github.com/cch123/asmshare/blob/master/layout.md

【曹大 Go 彙編】https://xargin.com/plan9-assembly

【曹大利用匯編寫的 goid 獲取】https://github.com/cch123/goroutineid



分享到:


相關文章: