「 defer如何延遲,因何倒序?」
「 defer函數參數怎麼傳遞?」
「 加個閉包再多套幾層,你還hold住嗎?」
「 都說defer1.12性能有坑,那坑從何來?又該怎麼填?」
如何延遲?因何倒序?
Go語言的defer是一個很方便的機制,能夠把某些函數調用推遲到當前函數返回前才實際執行。我們可以很方便的用defer關閉一個打開的文件、釋放一個Redis連接,或者解鎖一個Mutex。而且Go語言在設計上保證,即使發生panic,所有的defer調用也能夠被執行。不過多個defer函數是按照定義順序倒序執行的。我們通過一個例子來解釋defer函數的延遲與倒序。像這樣一段代碼,在Go1.12中編譯後的偽指令是這樣的:func f1() { r := runtime.deferproc(0, A) if r > 0 { goto ret } runtime.deferreturn() returnret: runtime.deferreturn()}其中與defer指令相關的有兩個部分。第一部分是deferproc,它負責保存要執行的函數信息,我們稱之為defer「註冊」。func deferproc(siz int32, fn *funcval) 從函數原型來看,deferproc函數有兩個參數,第一個是被註冊的defer函數的參數加返回值共佔多少字節;第二個參數是一個runtime.funcval結構體的指針,也就是一個Function Value,已介紹過,此不贅述。與defer指令相關的第二部分就是deferreturn,它被編譯器插入到函數返回以前調用,負責執行已經註冊的defer函數。所以defer函數之所以能延遲到函數返回前執行,就是因為先註冊,後調用。defer註冊信息會保存到defer鍊表。每個goroutine在運行時都對應一個runtime.g結構體,其中有一個_defer欄位,保存的就是defer鍊表的頭指針。deferproc新註冊的defr信息會添加到鍊表頭。deferreturn執行時也從鍊表頭開始,所以defer才會表現為倒序執行。理解了這些,就可以繼續細化,看看defer註冊時保存了什麼信息,defer鍊表中每個元素究竟是什麼結構了。defer鍊表項
defer鍊表鏈起來的是一個一個_defer結構體。type _defer struct { siz int32 started bool sp uintptr // sp at time of defer pc uintptr fn *funcval _panic *_panic // panic that is running defer link *_defer }- siz 由deferproc第一個參數傳入,就是defer函數參數加返回值的總大小。這段空間會直接分配在_defer結構體後面,用於在註冊時保存給defer函數傳入的參數,並在執行時直接拷貝到defer函數的調用者棧上。- started 標識defer函數是否已經開始執行;- pc 是deferproc函數返回後要繼續執行的指令地址;- fn 由deferproc的第二個參數傳入,也就是被註冊的defer函數;- _panic 是觸發defer函數執行的panic指針,正常流程執行defer時它就是nil;- link 自然是鏈到之前註冊的那個_defer結構體。這一篇我們只關注正常流程下defer函數的執行,不考慮panic或runtime.Goexit()的情況。defer傳參機制
func A1(a int) { fmt.Println(a)}func A() { a, b := 1, 2 defer A1(a) a = a + b fmt.Println(a, b)}這裡函數A註冊了一個defer函數A1,在A的函數棧幀中,局部變量區域存儲a=1,b=2。到deferproc函數註冊defer函數A1時,第一個參數是A1的參數加返回值共佔多少字節。A1沒有返回值,64位下一個整型參數佔用8位元組。第二個參數是函數A1。前面我們介紹過,沒有捕獲列表的Function Value,在編譯階段會做出優化,就是在只讀數據段分配一個共用的funcval結構體。如下圖中,函數A1的指令入口地址為addr1。在只讀數據段分配的指向A1指令入口的funcval結構體地址為addr2,所以deferproc函數第二個參數就是addr2。額外要注意的是,deferproc函數調用時,編譯器會在它自己的兩個參數後面,開闢一段空間,用於存放defer函數A1的返回值和參數。這一段空間會在註冊defer時,直接拷貝到_defer結構體的後面。A1隻有一個參數a=1,放在deferproc函數自己的兩個參數之後。注意deferproc函數的返回值空間並沒有分配在調用者棧上,而是放到了寄存器中,這和recover有關,且先忽略。圖:defer函數的參數怎麼傳給deferprocdeferproc函數執行,需要堆分配一段空間,用於放_defer結構體以及後面siz大小的參數與返回值。_defer結構體的第一個欄位,A1的參數加返回值共佔8位元組;defer函數尚未執行,所以started=false;sp就是調用者A的棧指針;pc就是deferproc函數的返回地址return addr;被註冊的function value為A1;defer結構體後面的8位元組用來保存傳遞給A1的參數。然後這個_defer結構體就被添加到defer鍊表頭,deferproc註冊結束。「頻繁的堆分配勢必影響性能,所以Go語言會預分配不同規格的deferpool,執行時從空閒_defer中取一個出來用。沒有空閒的或者沒有大小合適的,再進行堆分配。用完以後,再放回空閒_defer池。這樣可以避免頻繁的堆分配與回收。」deferproc結束後,接下來會執行到a=a+b這一步,所以,局部變量a被置為3。接下來會輸出:a=3,b=2。func A() { a, b := 1, 2 r := runtime.deferproc(8, A1,1) if r > 0 { goto ret } a = a + b fmt.Println(a, b)
runtime.deferreturn() returnret: runtime.deferreturn()}然後就到deferreturn執行defer鍊表這裡了。從當前goroutine找到鍊表頭上的這個_defer結構體,通過_defer.fn找到defer函數的funcval結構體,進而拿到函數A1的入口地址。接下來就可以調用A1了。調用A1時,會把_defer後面的參數與返回值整個拷貝到A1的調用者棧上。然後A1開始執行,輸出參數值a=1。這個例子的關鍵,是defer函數的參數在註冊時拷貝到堆上,執行時再拷貝到棧上。defer+閉包
既然deferproc註冊的是一個Function Value,下面就來看看有捕獲列表時是什麼情況。func A() { a, b := 1, 2 defer func(b int) { a = a+b fmt.Println(a, b) }(b) a = a + b fmt.Println(a, b)}這個例子中,defer函數不止要傳遞局部變量b做參數,還捕獲了外層函數的局部變量a,形成閉包。匿名函數會由編譯器按照A_func1這樣的形式命名。如下圖所示,假設這個閉包函數的指令入口地址為addr1。上圖中,由於捕獲變量a除了初始化賦值外還被修改過,所以A的局部變量a改為堆分配,棧上只存它的地址。創建閉包對象時,會堆分配一個funcval結構體,funcval.fn指向閉包函數入口addr1,捕獲列表中存儲a在堆上的地址。而這個funcval結構體本身的地址addr2,就是deferproc執行時,_defer結構體中的fn的值。別忘了,傳給defer函數的參數b=2,也要拷貝到_defer結構體後面。上圖所示_defer結構體被添加到defer鍊表頭以後,deferproc註冊結束。繼續執行後面的邏輯。到a=a+b這裡,a被置為3。下一步輸出a=3,b=2。接下來,deferreturn執行註冊的defer函數時,要把參數b拷貝到棧上的參數空間。還記得閉包函數執行時怎樣找到對應的捕獲列表嗎?通過寄存器存儲的funcval地址加上偏移,找到捕獲變量a的地址。func A() { a := new(int) *a = 1 b := 2 r := runtime.deferproc(8, A_func1,2) if r > 0 { goto ret } *a = *a + b fmt.Println(*a, b)
runtime.deferreturn()returnret: runtime.deferreturn()}func A_func1(b int){ a := (int *)([DX]+8) *a = *a + b fmt.Println(*a,b)}要注意捕獲變量a在堆上分配,閉包函數執行時,捕獲變量a=3,參數b=2。所以,接下來在defer函數中,捕獲變量a被置為5,最終輸出a=5,b=2。這個例子中,最關鍵的是分清defer傳參與閉包捕獲變量的實現機制。defer( A( B(c) ) )
func B(a int) int { a++ return a}func A(a int) { a++ fmt.Println(a)}func main() { a := 1 defer A(B(a)) a++ fmt.Println(a)}這個例子中,main函數註冊的defer函數是A,所以,defer鍊表項中_defer.fn存儲的是A的funcval指針。但是deferproc執行時,需要保存A的參數到_defer結構體後面。這就需要在defer註冊時拿到B的返回值。既然B會在defer註冊時執行,那麼對B(a)求值時a=1。函數B的返回值就是2,也就是defer註冊時保存的參數值為2,所以defer函數A執行時就會輸出3。defer嵌套
這一次,我們拋開各種細節,只關注defer鍊表隨著defer函數的註冊與執行究竟會如何變化。func A(){ defer A1() defer A2() }func A2(){ defer B1() defer B2() }func A1(){ }這個例子中函數A註冊兩個defer,我們用函數名標記為A1和A2。到函數A返回前執行deferreturn時,會判斷defer鍊表頭上的defer是不是A註冊的。方法就是判斷_defer結構體記錄的sp是否等於A的棧指針.如果是A註冊的,就保存defer函數調用的相關信息,然後把這一項從defer鍊表中移除,然後調用函數A2,A2執行時又註冊兩個defer,記為B1和B2。函數A2返回前同樣去執行defer鍊表,同樣判斷是否是自己註冊的defer函數。所以B2執行,之後B1執行。此時A2仍然不知道自己註冊的defer函數已經執行完了,直到下一個_defer.sp不等於A2的棧指針,A2註冊的defer執行完,A2就可以結束了。因為A1是函數A註冊的defer函數,所以又回到A的defer執行流程。A1結束後,defer鍊表為空,函數A結束。這個例子的關鍵是defer鍊表註冊時添加鍊表項,執行時移除鍊表項的用法。「理解了defer註冊與執行的邏輯,再配合之前介紹過的Function Value、函數調用棧等內容,就很容易理解上面幾個例子,也就不用刷那些重複的defer面試題了。」defer1.12,有點兒慢
因為defer真的很方便,所以大家都已經習慣了隨手使用它。但是與一般的函數調用比起來,defer1.12的實現方式會在調用時造成較大的額外開銷,尤其是在鎖釋放這種場景。因此經常被一些庫設計者所詬病,甚至有些項目的注釋中寫明了不用defer能節省多少多少納秒。1. _defer結構體堆分配,即使有預分配的deferpool,也需要去堆上獲取與釋放。而且defer函數的參數還要在註冊時從棧拷貝到堆,執行時又要從堆拷貝到棧。2. defer信息保存到鍊表,而鍊表操作比較慢。但是,defer作為一個關鍵的語言特性,怎能如此受人詬病?所以GO語言在1.13和1.14中做出了不同的優化。defer1.13:先提個30%
Go1.13中defer性能的優化點,主要集中在減少defer結構體堆分配。我們通過一個例子,看看它是怎樣做到的。func A() { defer B(10) }func B(i int) { }像這樣一段代碼,在Go1.13中編譯後的偽指令是這樣的:func A() { var d struct { runtime._defer i int } d.siz = 0 d.fn = B d.i = 10 r := runtime.deferprocStack(&d._defer) if r > 0 { goto ret } runtime.deferreturn() returnret: runtime.deferreturn()}注意上面的結構體d,它由兩部分組成,一個是runtime._defer結構體,一個是傳給defer函數B的參數。它們被定義為函數A的局部變量,執行階段會分配在函數棧幀的局部變量區域。接下來的runtime.deferprocStack則會把棧上分配的_defer結構體註冊到defer鍊表。通過這樣的方式避免在堆上分配_defer結構體。值得注意的是,1.13版本中並不是所有defer都能夠在棧上分配。循環中的defer,無論是顯示的for循環,還是goto形成的隱式循環,都只能使用1.12版本中的處理方式在堆上分配。即使只執行一次的for循環也是一樣。for i:=0; i< n; i++{ defer B(i)}.
again: defer B() if i<n { n++ goto again }所以Go1.13中,runtime._defer結構體增加了一個欄位heap,用於標識是否為堆分配。type _defer struct { siz int32 started bool heap bool //標識是否為堆分配 sp uintptr pc uintptr fn *funcval _panic *_panic link *_defer}defer函數的執行在1.13中沒有變化,依然通過deferreturn實現,依然需要把_defer結構體後面的參數與返回值空間,拷貝到defer函數的調用者棧上。只不過不是從堆上拷貝到棧上,而是從棧上的局部變量空間拷貝到參數空間。1.13版本的defer減少了_defer結構體的堆分配,但是仍然要使用defer鍊表。官方提供的性能優化在30%左右,那1.14版本又做出了怎樣的優化呢?open coded defer放大招
減少_defer結構體的堆分配,也是1.14版本中defer性能優化要持續踐行的策略。但是具體做法與1.13版本不同。func A(i int) { defer A1(i, 2*i) if(i > 1){ defer A2("Hello", "eggo") } return}func A1(a,b int){ }func A2(m,n string){ }上面這個例子中,函數A註冊兩個defer函數A1和A2,不過函數A2要到執行階段根據條件判斷是否要執行。先看defer函數A1這部分編譯後的偽指令,Go1.14中會把A1需要的參數定義為局部變量,並在函數返回前直接調用A1。func A(i int){ var a, b int = i, 2*i A1(a, b) return }通過這樣的方式不僅不用構建_defer結構體,也用不到defer鍊表,但是到defer函數A2這裡就行不通了。因為A2不一定要被執行,這要在執行階段根據參數i的值來決定。Go1.14通過增加一個標識變量df來解決這類問題。用df中的每一位對應標識當前函數中的一個defer函數是否要執行。例如,函數A1要被執行,所以就通過df |= 1把df第一位置為1;在函數返回前再通過df&1判斷是否要調用函數A1。func A(i int){ var df byte var a, b int = i, 2*i df |= 1
if df&1 > 0 { df = df&^1 A1(a, b) } return }所以像A2這樣有條件執行的defer函數就可以像下面這樣處理了。根據條件判斷是否要把對應標識位置為1,函數返回前同樣要根據標識符來判斷是否要調用。func A(i int){ var df byte var a, b int = i, 2*i df |= 1
var m,n string = "Hello", "eggo" if i > 1 { df |= 2 } if df&2 > 0 { df = df&^2 A2(m, n) } if df&1 > 0 { df = df&^1 A1(a, b) } return }Go1.14把defer函數在當前函數內展開並直接調用,這種方式被稱為open coded defer。這種方式不僅不用創建_defer結構體,也脫離了defer鍊表的束縛。不過這種方式依然不適用於循環中的defer,所以1.12版本defer的處理方式是一直保留的。性能測試
func BenchmarkDefer(b *testing.B) { for i := 0; i < b.N; i++ { Defer(i) }}func Defer(i int) (r int) { defer func() { r -= 1 r |= r>>1 r |= r>>2 r |= r>>4 r |= r>>8 r |= r>>16 r |= r>>32 r += 1 }() r = i * i return}goos: windowsgoarch: amd64pkg: fengyoulin.com/research/defer_benchBenchmarkDefer-8 30000000 41.1 ns/opPASSgoos: windowsgoarch: amd64pkg: fengyoulin.com/research/defer_benchBenchmarkDefer-8 38968154 30.2 ns/opPASSgoos: windowsgoarch: amd64pkg: fengyoulin.com/research/defer_benchBenchmarkDefer-8 243550725 4.62 ns/opPASS從deferproc到deferprocStack,約有25%的性能提升,而open coded defer幾乎提升了一個數量級。但是,必須要強調的是,我們一直在梳理的都是程序正常執行時defer的處理邏輯。一旦發生panic或者調用了runtime.Goexit函數,在這之後的正常邏輯就都不會執行了,而是直接去執行defer鍊表。那些使用open coded defer在函數內展開,因而沒有被註冊到鍊表的defer函數要通過棧掃描的方式來發現。Go1.14中runtime._defer結構體又增加了幾個欄位:type _defer struct { siz int32 started bool heap bool openDefer bool //1 sp uintptr pc uintptr fn *funcval _panic *_panic link *_defer fd unsafe.Pointer //2 varp uintptr //3 framepc uintptr //4}藉助這些信息,panic處理流程可以通過棧掃描的方式找到這些沒有被註冊到defer鍊表的defer函數,並按照正確的順序執行。所以,實際上Go1.14版本中defer的確變快了,但panic變得更慢了.