去年開始寫文章的第一篇就是關于 defer,名字比較文藝:《Golang 之輕松化解 defer 的溫柔陷阱》,還被吐槽了,因為這篇文章,到《Go 夜讀》講了一期,不過當時純粹是應用層面的,也還沒有跳進 Go 原始碼這個大坑,文章看著比較清新,也沒有大段的原始碼決議,
自從聽了曹大在《Go 夜讀》分享的 Go 匯編,以及研讀了阿波張的 Go 調度器原始碼分析的文章后,各種原始碼、匯編滿天飛……
上次歐神寫了一篇《Go GC 20 問》,全文也沒有一行原始碼,整體讀下來很暢快,今天這篇也來嘗試一下這種寫法,不過,我們先從一個小的主題開始:defer 鏈表是如何被遍歷并執行的,
關于 defer 的原始碼分析文章,網路上也有很多,不過,很少有能完全說明白這個話題的,除了阿波張的,
我們知道,為了在退出函式前執行一些資源清理的操作,例如關閉檔案、釋放連接等,會在函式里寫上多個 defer 陳述句,被 defered 的函式,以“先進后出”的順序,在 RET 指令前得以執行,
在一條函式呼叫鏈中,多個函式中會出現多個 defer 陳述句,例如:a() -> b() -> c() 中,每個函式里都有 defer 陳述句,而這些 defer 陳述句會創建對應個數的 _defer 結構體,這些結構體以鏈表的形式掛在 goroutine 結構體下,看起來像這樣:

在編譯器的加持下,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 匯編的堆疊幀說起了,先看一個匯編函式的宣告:
TEXT runtime·gogo(SB), NOSPLIT, $16-8
最后兩個數字表示 gogo 函式的堆疊幀大小為 16B,即函式的區域變數和為呼叫子函式準備的引數和回傳值需要 16B 的堆疊空間;引數和回傳值的大小加起來是 8B,實際上 gogo 函式的宣告是這樣的:
// func gogo(buf *gobuf)
引數及回傳值的大小是給呼叫者“看”的,呼叫者根據這個數字可以構造堆疊:準備好被調函式需要的引數及回傳值,
典型的函式呼叫場景下引數布局圖如下圖:

左圖中,主調函式準備好呼叫子函式的引數及回傳值,執行 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 鏈表也會被逐漸“消耗”完,
使用一個阿波張文章中的例子:
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)
}
執行完 f 函式時,最侄訓進入 deferreturn 函式:
func deferreturn(arg0 uintptr) {
gp := getg()
d := gp._defer
if d == nil {
return
}
......
switch d.siz {
case 0:
// Do nothing.
case sys.PtrSize:
*(*uintptr)(unsafe.Pointer(&arg0)) = *(*uintptr)(deferArgs(d))
default:
memmove(unsafe.Pointer(&arg0), deferArgs(d), uintptr(d.siz)) // 移動引數
}
fn := d.fn
d.fn = nil
gp._defer = d.link
freedefer(d)
_ = fn.fn
jmpdefer(fn, uintptr(unsafe.Pointer(&arg0)))
}
免不了還是要看一下代碼,不然的話很難講清楚,
因為我們是在遍歷 _defer 鏈表,所以得有一個終止的條件:
d := gp._defer
if d == nil {
return
}
也就是當 _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 函式:
jmpdefer(fn, uintptr(unsafe.Pointer(&arg0)))
核心在于 jmpdefer 所做的事:
TEXT runtime·jmpdefer(SB), NOSPLIT, $0-16
MOVQ fv+0(FP), DX // fn // defer 的函式的地址
MOVQ argp+8(FP), BX
LEAQ -8(BX), SP // caller sp after CALL
MOVQ -8(SP), BP // restore BP as if deferreturn returned (harmless if framepointers not in use)
SUBQ $5, (SP) // return to CALL again
MOVQ 0(DX), BX
JMP BX // but first run the deferred function
首先將 sum 函式的地址放到 DX 暫存器中,最后通過 JMP 指令去執行,
MOVQ argp+8(FP), BX
LEAQ -8(BX), SP // caller sp after CALL // 執行 CALL 指令后 f 函式的堆疊頂
這兩行實際上是調整了下當前 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 函式的效果,當然,堆疊卻不會在增長!

jmpdefer 函式的最后會執行 sum 函式,看起來就像是 f 函式親自呼叫 sum 函式一樣,引數、回傳值都是就緒的,
等到 sum 函式執行完,執行流程就會跳轉到 call deferreturn 指令處重新進入 deferreturn 函式,遍歷完所有的 _defer 結構體,執行完所有的被 defered 的函式,才真正執行完 deferretrun 函式,

到這里,全文就結束了,我們可以看到,實作遍歷 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
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/33537.html
標籤:Go
上一篇:帶你入門Go的訊息佇列NSQ
