Go的延遲調用機制會在當前函數返回前執行傳入的函數,它會經常被用于關閉文件描述符、關閉數據庫連接以及解鎖資源。之前的文章(?Go defer(一):延遲調用的使用及其底層實現原理詳解?)詳細介紹了defer的使用以及其底層實現原理,本文則以Go 1.16版本以及AMD 64架構為實驗環境,從匯編語言的角度去分析defer的實現原理,主要涉及defer參數傳遞方式、閉包、返回值修改、內存分配方式等內容。
1 使用特性
1.1 函數傳參
在使用defer實現延遲調用的時候,需要特別注意的是注冊函數的參數傳參方式,如果直接使用的常規的函數傳參,那么參數值在函數注冊到defer鏈表上時就已經被確定了,這時候延遲的只是函數調用的時機,參數值早在一開始構建_defer結構體的時候就已經確定了(如果是引用語義的類型,則需要特別注意,這里僅以int類型作為參數進行分析)。以下面的sum函數為例,分析其匯編代碼不難發現:
- _defer結構體的內存布局會將注冊函數所需的參數、返回值所需的內存空間緊挨著_defer結構體。這里需要特別注意下,在_defer結構體的內存布局中,前8個字節包含siz(4字節)、started(1字節)、heap(1字節)、openDefer(1字節)。關于Go結構體內存布局,感興趣的可以參考下Go struct:結構體使用基礎以及內存布局。
- 按照函數傳參的方式進行defer注冊,編譯器直接將sum函數所需要的參數值賦值到了_defer結構體存儲func參數的棧空間(值拷貝)
- 由于函數傳參的方式是以值拷貝實現的,因此后續代碼執行的變量自增操作不會對之前延遲注冊函數所需的參數產生影響。(注意:Go的引用類型,slice、map、chan、interface,這些類型由于底層包含指針,雖然是值拷貝,但受指針影響,后續的修改可能會對延遲調用產生影響)
func sum(a, b int) int {return a + b
}func main() {a, b := 1, 2// 通過函數傳參的方式注冊的延遲調用,在注冊時已經將函數的形參值確定了defer sum(a, b) a++
}"".main STEXT size=197 args=0x0 locals=0x90 funcid=0x00x0000 00000 (main.go:20) TEXT "".main(SB), ABIInternal, $144-0...0x002f 00047 (main.go:22) MOVQ $1, "".a+32(SP) // 變量a0x0038 00056 (main.go:22) MOVQ $2, "".b+24(SP) // 變量b0x0041 00065 (main.go:23) MOVL $24, ""..autotmp_2+40(SP) // SP+40~SP+112的棧空間存儲的是 _defer結構體0x0049 00073 (main.go:23) LEAQ "".sum·f(SB), AX0x0050 00080 (main.go:23) MOVQ AX, ""..autotmp_2+64(SP)0x0055 00085 (main.go:23) MOVQ "".a+32(SP), AX0x005a 00090 (main.go:23) MOVQ AX, ""..autotmp_2+112(SP)0x005f 00095 (main.go:23) MOVQ "".b+24(SP), AX0x0064 00100 (main.go:23) MOVQ AX, ""..autotmp_2+120(SP)0x0069 00105 (main.go:23) LEAQ ""..autotmp_2+40(SP), AX0x006e 00110 (main.go:23) MOVQ AX, (SP)0x0072 00114 (main.go:23) PCDATA $1, $00x0072 00114 (main.go:23) CALL runtime.deferprocStack(SB)0x0077 00119 (main.go:23) TESTL AX, AX // 測試返回值(AX寄存器),如果deferprocStack調用成功,return0()會將AX設置為00x0079 00121 (main.go:23) JNE 161 // 如果返回值不為0,則跳轉到161,不再執行main函數中的后續代碼0x007b 00123 (main.go:23) JMP 1250x007d 00125 (main.go:27) MOVQ "".a+32(SP), AX0x0082 00130 (main.go:27) INCQ AX // a++0x0085 00133 (main.go:27) MOVQ AX, "".a+32(SP)0x008a 00138 (main.go:28) XCHGL AX, AX0x008b 00139 (main.go:28) CALL runtime.deferreturn(SB) // 執行延遲調用0x0090 00144 (main.go:28) MOVQ 136(SP), BP // 恢復調用者的BP0x0098 00152 (main.go:28) ADDQ $144, SP // 清理棧空間0x009f 00159 (main.go:28) NOP0x00a0 00160 (main.go:28) RET0x00a1 00161 (main.go:23) XCHGL AX, AX0x00a2 00162 (main.go:23) CALL runtime.deferreturn(SB)0x00a7 00167 (main.go:23) MOVQ 136(SP), BP0x00af 00175 (main.go:23) ADDQ $144, SP0x00b6 00182 (main.go:23) RET0x00b7 00183 (main.go:23) NOP...
1.2 閉包
對于傳統值拷貝的類型,想要在defer執行函數時,實時獲取最新的參數值,則可以借助Go語言里面的閉包特性進行實現。閉包特性簡單來說就是使用指針取代具體值進行傳遞,這樣在后續需要使用該變量時,通過對地址的解引用來得到實時的值,從而實現變量最新值的獲取。
閉包=函數地址 + 引用變量的地址
當函數引用外部作用域的變量時,我們稱之為閉包。在底層實現上,閉包由函數地址和引用到的變量的地址組成,并存儲在一個結構體里,在閉包被傳遞時,實際是該結構體的地址被傳遞。
type Closure struct {F func()() // 函數地址 uintptri *int // 引用變量的地址 }
還是以sum函數為例,其中兩個參數a,b,采用閉包的方式構建匿名函數注冊延遲調用,分析其匯編語言發現,主要的流程大致與函數傳參的方式一致,區別在于:
- 此時defer注冊的是匿名函數func1,不再是sum函數,新函數僅需要兩個變量,而沒有返回值
- func1函數所需的參數依舊保存在_defer結構體之后,只是編譯器通過分析代碼,對不同處境的變量做了不同的處理:
- 對于后續不會再被改變的變量,編譯器直接進行了值拷貝,例如此例中的變量b
- 如果后續還會對變量進行修改,則編譯器將其內存地址保存到了_defer結構體之后,例如此例中的變量a
- 由于參數a保存的是其地址,那么在執行func1時,對其進行解引用拿到的值就是執行了自增之后的值,既實際sum函數執行時,兩個參數的值a=2,b=2。
func sum(a, b int) int {return a + b
}func main() {a, b := 1, 2defer func() {sum(a, b)}()a++
}"".main STEXT size=170 args=0x0 locals=0x80 funcid=0x00x0000 00000 (main.go:20) TEXT "".main(SB), ABIInternal, $128-0...0x0021 00033 (main.go:22) MOVQ $1, "".a+24(SP)0x002a 00042 (main.go:22) MOVQ $2, "".b+16(SP)0x0033 00051 (main.go:23) MOVL $16, ""..autotmp_2+32(SP) // 此時注冊的延遲調用函數為func匿名函數,僅有sum函數的兩個變量,沒有返回值0x003b 00059 (main.go:23) LEAQ "".main.func1·f(SB), AX0x0042 00066 (main.go:23) MOVQ AX, ""..autotmp_2+56(SP)0x0047 00071 (main.go:23) LEAQ "".a+24(SP), AX // 由于后面的代碼還會對a進行修改,所以此處保存的是a的內存地址(閉包)0x004c 00076 (main.go:23) MOVQ AX, ""..autotmp_2+104(SP)0x0051 00081 (main.go:23) MOVQ "".b+16(SP), AX // b在后續不會再被修改,所以編譯器進行了優化直接存儲b的值0x0056 00086 (main.go:23) MOVQ AX, ""..autotmp_2+112(SP)0x005b 00091 (main.go:23) LEAQ ""..autotmp_2+32(SP), AX0x0060 00096 (main.go:23) MOVQ AX, (SP)0x0064 00100 (main.go:23) PCDATA $1, $00x0064 00100 (main.go:23) CALL runtime.deferprocStack(SB)0x0069 00105 (main.go:23) TESTL AX, AX // 判斷deferprocStack函數是否正常執行return0(),如果沒有正常執行直接跳轉至143進行棧的清理工作0x006b 00107 (main.go:23) JNE 1430x006d 00109 (main.go:23) JMP 1110x006f 00111 (main.go:26) MOVQ "".a+24(SP), AX0x0074 00116 (main.go:26) INCQ AX0x0077 00119 (main.go:26) MOVQ AX, "".a+24(SP)0x007c 00124 (main.go:27) XCHGL AX, AX0x007d 00125 (main.go:27) NOP0x0080 00128 (main.go:27) CALL runtime.deferreturn(SB)0x0085 00133 (main.go:27) MOVQ 120(SP), BP0x008a 00138 (main.go:27) SUBQ $-128, SP0x008e 00142 (main.go:27) RET0x008f 00143 (main.go:23) XCHGL AX, AX0x0090 00144 (main.go:23) CALL runtime.deferreturn(SB)0x0095 00149 (main.go:23) MOVQ 120(SP), BP0x009a 00154 (main.go:23) SUBQ $-128, SP0x009e 00158 (main.go:23) RET...
1.3 返回值修改
defer注冊的函數是在主流程結束,函數返回之前被調用,那么如果在defer延遲調用的函數中對返回值進行修改,又會有怎么樣的現象呢?從匯編的角度來看,return分為兩步:先是對返回值進行賦值,最后函數結束時執行一個空的返回操作,而defer的執行時機則穿插在這兩步之間。具體的執行流程如下:
-
返回值 = xxx
-
調用defer注冊的函數
-
空的return
根據上述的流程不難發現,可以在defer注冊的延遲調用函數內部對返回值進行修改或賦值,下面從返回值重新賦值、直接修改返回值兩種情況來分析下defer的延遲調用對返回值的影響。
1.3.1 返回值重新賦值
編寫如下的代碼,主要流程為定義一個變量t,通過閉包的形式對其在defer匿名函數中進行修改,并將該變量作為返回值進行返回。此函數有一個有名返回值r,既最后返回的值是一個內部的新變量,并不是直接定義的返回值變量r。通過匯編語言來分析下變量定義以及defer注冊之后,其函數棧的具體情況:
- 首先,Go的函數棧分布情況為:調用者函數棧幀會為被調用者預留參數、返回值所需的內存空間。如下圖所示,main函數棧幀中會預留f函數的返回值空間。
- 隨后,進入f函數內部,有一個局部變量t,以及在棧上分配的_defer結構體,該_defer的延遲調用函數為匿名f.func1,所需參數為t(由于閉包,存儲的是t的內存地址)
// 該函數返回值為5
func f() (r int){t := 5defer func(){t = t + 5}()return t
}"".f STEXT size=155 args=0x8 locals=0x68 funcid=0x0...0x0021 00033 (main.go:4) MOVQ $0, "".r+112(SP) // 調用者的函數棧空間,用于存儲f函數的返回值0x002a 00042 (main.go:5) MOVQ $5, "".t+8(SP)0x0033 00051 (main.go:6) MOVL $8, ""..autotmp_2+16(SP)0x003b 00059 (main.go:6) LEAQ "".f.func1·f(SB), AX0x0042 00066 (main.go:6) MOVQ AX, ""..autotmp_2+40(SP)0x0047 00071 (main.go:6) LEAQ "".t+8(SP), AX0x004c 00076 (main.go:6) MOVQ AX, ""..autotmp_2+88(SP)0x0051 00081 (main.go:6) LEAQ ""..autotmp_2+16(SP), AX0x0056 00086 (main.go:6) MOVQ AX, (SP)0x005a 00090 (main.go:6) PCDATA $1, $00x005a 00090 (main.go:6) CALL runtime.deferprocStack(SB)0x005f 00095 (main.go:6) NOP
?
隨后,代碼開始執行return語句:
- 將t值賦值給r作為返回值,從匯編語言的角度看就是將t的值拷貝到r的內存空間(調用者預留的空間內)。
- 開始執行延遲調用函數,既f.func1函數,將t的變量值?5,此處操作與變量r完全沒有關系。
- 最后執行一個空的返回操作。
0x0060 00096 (main.go:6) TESTL AX, AX0x0062 00098 (main.go:6) JNE 1290x0064 00100 (main.go:6) JMP 1020x0066 00102 (main.go:9) MOVQ "".t+8(SP), AX 0x006b 00107 (main.go:9) MOVQ AX, "".r+112(SP) // 將t的值賦值給r,既將其存儲到調用者的f函數返回值棧空間內0x0070 00112 (main.go:9) XCHGL AX, AX0x0071 00113 (main.go:9) CALL runtime.deferreturn(SB) // 執行延遲調用,將t的值?50x0076 00118 (main.go:9) MOVQ 96(SP), BP0x007b 00123 (main.go:9) ADDQ $104, SP0x007f 00127 (main.go:9) NOP0x0080 00128 (main.go:9) RET // 相當于執行空的return操作..."".f.func1 STEXT nosplit size=21 args=0x8 locals=0x0 funcid=0x00x0000 00000 (main.go:6) TEXT "".f.func1(SB), NOSPLIT|ABIInternal, $0-80x0000 00000 (main.go:6) FUNCDATA $0, gclocals·1a65e721a2ccc325b382662e7ffee780(SB)0x0000 00000 (main.go:6) FUNCDATA $1, gclocals·69c1753bd5f81501d95132d08af04464(SB)0x0000 00000 (main.go:7) MOVQ "".&t+8(SP), AX // AX = 捕獲變量 t 的地址0x0005 00005 (main.go:7) MOVQ "".&t+8(SP), CX // CX = 捕獲變量 t 的地址0x000a 00010 (main.go:7) MOVQ (CX), CX // CX = *CX (解引用獲取 t 的值)0x000d 00013 (main.go:7) ADDQ $5, CX // CX = t + 50x0011 00017 (main.go:7) MOVQ CX, (AX) // *AX = CX (將結果寫回 t)0x0014 00020 (main.go:8) RET
1.3.2 直接修改返回值
本例在之前的代碼基礎上進行修改,將返回值的名稱修改為t,既函數內部不再重新定義一個新的變量,而是直接對返回值進行操作,此時編譯器做出如下動作:
- 首先,main函數棧幀中會預留f函數的返回值t,f函數對t的操作都直接會反應到返回值上。
- 隨后,在棧上分配的_defer結構體,該_defer的延遲調用函數為匿名f.func1,所需參數為t(由于閉包,存儲的是t的內存地址,既返回值的地址)
- 最后,deferreturn函數執行延遲調用時,執行匿名f.func1函數,會通過內存地址解引用找到t,對其進行?5操作,這里的修改直接反映在f.func1函數的返回值上,因此最終f函數會返回10。
2 實現方式
2.1 堆分配
隨著Go語言的不斷優化發展,_defer結構體內存分配方式也不再僅有最原始的堆分配,新增了棧分配方式以及開發編碼。雖然棧分配相比堆分配,性能占有,但其適用范圍有效,而堆分配則適用于所用的情況,下面以循環內部注冊defer觸發編譯時期無法確定具體defer數量為例,分析下此時函數棧、堆的具體情況:
- 首先,由于_defer結構體所需的內存是在堆上進行分配的,那么函數棧幀中僅需存儲調用deferproc所需的參數值、defer延遲調用函數所需的參數以及返回值空間。
- 隨后,調用deferproc函數,通過newdefer函數在堆上分配一塊_defer結構體內存,將參數拷貝到對應位置,同時在defer結構體常規成員變量之后。緊接著拷貝注冊的延遲調用函數所需參數以及返回值,并將該_defer結構體加入到Goroutine到_defer鏈表頭部。
func sum(a, b int) int {return a + b
}func main() {for i := 0; i < 3; i++ {// 循環內的 defer 導致無法在編譯時確定數量defer sum(i, i+1)}
}"".main STEXT size=170 args=0x0 locals=0x38 funcid=0x0...0x0021 00033 (main.go:9) MOVQ $0, "".i+40(SP) // 變量i初始化0x002a 00042 (main.go:9) JMP 440x002c 00044 (main.go:9) CMPQ "".i+40(SP), $3 // i與3進行比較0x0032 00050 (main.go:9) JLT 540x0034 00052 (main.go:9) JMP 1430x0036 00054 (main.go:11) MOVQ "".i+40(SP), AX0x003b 00059 (main.go:11) MOVQ "".i+40(SP), CX0x0040 00064 (main.go:11) MOVL $24, (SP) // deferproc 函數的第一個參數siz0x0047 00071 (main.go:11) LEAQ "".sum·f(SB), DX0x004e 00078 (main.go:11) MOVQ DX, 8(SP) // deferproc 函數的第二個參數fn0x0053 00083 (main.go:11) MOVQ AX, 16(SP) // "".sum·f 函數的第一個參數0x0058 00088 (main.go:11) LEAQ 1(CX), AX // AX = i+10x005c 00092 (main.go:11) MOVQ AX, 24(SP) // "".sum·f 函數的第二個參數0x0061 00097 (main.go:11) PCDATA $1, $00x0061 00097 (main.go:11) CALL runtime.deferproc(SB) // 調用 deferproc函數從堆上分配_defer所需的內存0x0066 00102 (main.go:11) TESTL AX, AX // return0()函數是否執行成功0x0068 00104 (main.go:11) JNE 1250x006a 00106 (main.go:11) JMP 1080x006c 00108 (main.go:9) PCDATA $1, $-10x006c 00108 (main.go:9) JMP 1100x006e 00110 (main.go:9) MOVQ "".i+40(SP), AX0x0073 00115 (main.go:9) INCQ AX0x0076 00118 (main.go:9) MOVQ AX, "".i+40(SP)0x007b 00123 (main.go:9) JMP 440x007d 00125 (main.go:11) PCDATA $1, $00x007d 00125 (main.go:11) XCHGL AX, AX0x007e 00126 (main.go:11) NOP0x0080 00128 (main.go:11) CALL runtime.deferreturn(SB)0x0085 00133 (main.go:11) MOVQ 48(SP), BP0x008a 00138 (main.go:11) ADDQ $56, SP0x008e 00142 (main.go:11) RET...
2.2 棧分配
棧分配適用的條件沒有堆分配那么多,僅適用于函數不逃逸且defer
?數量確定的場景,由于此時_defer結構體是在函數棧幀上分配的,那么只需移動SP的值就可以完成_defer結構體內存的回收,執行效率很高。對比堆分配,棧分配只是將內存空間放在了棧上,_defer內存布局、延遲調用函數參數及返回值存儲位置與堆分配完全一致。
func sum(a, b int) int {return a + b
}func main() {defer sum(1, 2)
}"".main STEXT size=126 args=0x0 locals=0x80 funcid=0x0...0x001d 00029 (main.go:8) MOVL $24, ""..autotmp_0+24(SP)0x0025 00037 (main.go:8) LEAQ "".sum·f(SB), AX0x002c 00044 (main.go:8) MOVQ AX, ""..autotmp_0+48(SP)0x0031 00049 (main.go:8) MOVQ $1, ""..autotmp_0+96(SP)0x003a 00058 (main.go:8) MOVQ $2, ""..autotmp_0+104(SP)0x0043 00067 (main.go:8) LEAQ ""..autotmp_0+24(SP), AX0x0048 00072 (main.go:8) MOVQ AX, (SP)0x004c 00076 (main.go:8) PCDATA $1, $00x004c 00076 (main.go:8) CALL runtime.deferprocStack(SB)...
2.3 開放編碼
使用go tool compile -S -l main.go
命令查看了下述簡單代碼在開放編碼下的匯編語言,可以發現編譯器沒有再調用deferprocStack、deferproc去為每個defer生成一個defer結構體,而是直接編譯成函數調用的方式,相當于把延遲調用改寫成了在函數返回之前需要進行的正常函數調用。
func sum(a, b int) int {return a + b
}func main() {defer sum(1, 2)
}$ go tool compile -S -l main.go "".main STEXT size=140 args=0x0 locals=0x40 funcid=0x0...0x0029 00041 (main.go:11) FUNCDATA $4, "".main.opendefer(SB) // 標記使用了開放編碼0x0029 00041 (main.go:11) MOVB $0, ""..autotmp_0+31(SP)0x002e 00046 (main.go:12) LEAQ "".sum·f(SB), AX0x0035 00053 (main.go:12) MOVQ AX, ""..autotmp_1+48(SP)0x003a 00058 (main.go:12) MOVQ $1, ""..autotmp_2+40(SP)0x0043 00067 (main.go:12) MOVQ $2, ""..autotmp_3+32(SP)0x004c 00076 (main.go:13) MOVB $0, ""..autotmp_0+31(SP)0x0051 00081 (main.go:13) MOVQ ""..autotmp_2+40(SP), AX0x0056 00086 (main.go:13) MOVQ ""..autotmp_3+32(SP), CX0x005b 00091 (main.go:13) MOVQ AX, (SP)0x005f 00095 (main.go:13) MOVQ CX, 8(SP)0x0064 00100 (main.go:13) PCDATA $1, $10x0064 00100 (main.go:13) CALL "".sum(SB) // 直接進行了函數調用0x0069 00105 (main.go:13) MOVQ 56(SP), BP0x006e 00110 (main.go:13) ADDQ $64, SP0x0072 00114 (main.go:13) RET0x0073 00115 (main.go:13) CALL runtime.deferreturn(SB)0x0078 00120 (main.go:13) MOVQ 56(SP), BP0x007d 00125 (main.go:13) ADDQ $64, SP0x0081 00129 (main.go:13) RET...