【Golang】脫胎換骨的defer

2021-12-29 幼麟實驗室

 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/opPASS

 goos: windowsgoarch: amd64pkg: fengyoulin.com/research/defer_benchBenchmarkDefer-8        38968154        30.2 ns/opPASS

goos: 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變得更慢了.

相關焦點

  • 聊聊golang的defer
    序本文主要研究一下golang的deferdeferreturn先賦值(對於命名返回值),然後執行defer,最後函數返回
  • Golang 語言中的 defer 怎麼使用?
    defer 所在的函數或方法中,如果調用 os.Exit(1),defer 即便註冊,也不會執行。defer 必須在函數和方法中才可以使用,並且 defer 後面必須是函數(自定義和部分內置函數)或方法,defer 函數的實參是值拷貝。
  • Golang-Defer
    Go官方文檔中對defer的執行時機做了闡述,分別是。包裹defer的函數返回時包裹defer的函數執行到末尾時所在的goroutine發生panic時defer執行順序當一個方法中有多個defer時, defer會將要延遲執行的方法「壓棧」,當defer被觸發時,將所有「壓棧」的方法「出棧」並執行。
  • golang中defer的執行過程詳解
    (給Go開發大全加星標)來源:XITEHIPhttps://www.jianshu.com/p/23402fa5f5ba【導讀】golang
  • Go 經典入門系列 28:Defer
    什麼是 defer? defer 語句的用途是:含有 defer 語句的函數,會在該函數將要返回之前,調用另一個函數。這個定義可能看起來很複雜,我們通過一個示例就很容易明白了。]在 playground 上運行: https://play.golang.org/p/IlccOsuSUE[3]切片: https://studygolang.com/articles/12121[4]函數: https://studygolang.com/articles/11892[5]方法: https://studygolang.com/articles
  • golang的defer使用相關
    ()) fmt.Println("-") deferFunc3() fmt.Println("-") deferFunc4()}func deferFunc1() { i := 1 defer fmt.Print(i) i = 2 return}func deferFunc2() (result int) { i := 1 defer func() { result
  • Golang之defer進階
    fmt.Printf("in defer: x = %d\n", x) x = 7 return 9}func test2() (x int) { x = 7 defer fmt.Printf("in defer: x = %d\n", x) return 9}func test3() (x int) { defer func() { fmt.Printf("in
  • Golang入門教程——基本操作篇
    defergolang的函數當中有一個特殊的用法,就是defer。這個用法據說其他語言也有,但是我暫時沒有見到過。defer是一個關鍵字,用它修飾的語句會被存入棧中,直到函數退出的時候執行。比如:func main() { defer fmt.Println("world") fmt.Println("hello")}上面這兩行代碼雖然defer的那一行在先,但是並不會被先執行,而是等main函數執行退出之前才會執行。
  • Golang中defer的實現原理
    前言在Go語言中,可以使用關鍵字defer向函數註冊退出調用,即主函數退出時,defer後的函數才被調用。defer語句的作用是不管程序是否出現異常,均在函數退出時自動執行相關代碼。:定義defer類似於入棧操作,執行defer類似於出棧操作,先進後出3.defer可操作主函數返回值defer語句中的函數會在return語句更新返回值後在執行。
  • Golang中defer的使用三特性
    -2014-05-25.htmlGolang中的defer關鍵字實現比較特殊的功能,按照官方的解釋,defer後面的表達式會被放入一個列表中,在當前方法返回的時候,列表中的表達式就會被執行。一個方法中可以在一個或者多個地方使用defer表達式,這也是前面提到的,為什麼需要用一個列表來保存這些表達式。在Golang中,defer表達式通常用來處理一些清理和釋放資源的操作。defer的行為稍微複雜一些,想要徹底理解defer,需要了解Golang中defer相關的一些特性。
  • go 學習筆記之解讀什麼是defer延遲函數
    無 defer 的文件拷貝有 defer 的文件拷貝上述示例代碼簡單展示了 defer 關鍵字的基本使用方式,顯著的好處在於 Open/Close 是一對操作,不會因為寫到最後而忘記 Close 操作,而且連續依賴時也能正常保證延遲時機
  • golang標準庫log
    收錄於話題 #golang log.Print("my log") log.Printf("my log %d", 100) name := "tom" age := 20 log.Println(name, ",", age) log.Panic("致命錯誤!") // log.Fatal("致命錯誤!")
  • Golang中的Defer必掌握的7知識點
    本文整理的defer的全場景使用情況,部分場景源自網絡,加上自己的額外觀點和分析,完成了這份defer的7個隱性必備知識點。一個函數中,寫在前面的defer會比寫在後面的defer調用的晚。,defer後的語句後執行該知識點不屬於defer本身,但是調用的場景卻與defer有聯繫,所以也算是defer必備了解的知識點之一。
  • Golang 之輕鬆化解 defer 的溫柔陷阱
    = nil {defer f.Close()}在打開文件的語句附近,用defer語句關閉文件。這樣,在函數結束之前,會自動執行defer後面的語句來關閉文件。當然,defer會有小小地延遲,對時間要求特別特別特別高的程序,可以避免使用它,其他一般忽略它帶來的延遲。defer進階defer的底層原理是什麼?
  • Go 語言之 defer 的前世今生 - CSDN
    Jan 27, 2009. https://github.com/golang/go/commit/4a903e0b32be5a590880ceb7379e68790602c29d[Thompson, 2009] Ken Thompson. defer.
  • Go 語言之 defer 的前世今生
    /golang")6    }()7  }8}因而 defer 並不是免費的午餐,在一個複雜的調用中,當無法直接確定需要的產生的延遲調用的數量時,延遲語句將導致運行性能的下降。Jan 27, 2009. https://github.com/golang/go/commit/4a903e0b32be5a590880ceb7379e68790602c29d[Thompson, 2009] Ken Thompson. defer.
  • 深入剖析 defer 原理篇 —— 函數調用的原理?
    地址空間函數棧幀棧幀的劃定函數調用函數返回舉個例子總結本篇文章是深入剖析 golang 的 defer 的基礎知識準備,如果要完全理解 defer ,避免踩坑,這個章節的基礎知識必不可少在 golang 的一個函數的代碼裡,開頭會先保存 rbp 寄存器的值,保存到棧上,函數執行完之後,需要返回 caller 函數之前,需要恢復 rbp 寄存器。
  • golang channel 使用總結
    本文介紹了使用 golang channel 的諸多特性和技巧,已經熟悉了 go 語言特性的小夥伴也可以看看,很有啟發。不同於傳統的多線程並發模型使用共享內存來實現線程間通信的方式,golang 的哲學是通過 channel 進行協程 (goroutine) 之間的通信來實現數據共享:Do not communicate by sharing memory; instead, share memory by communicating.
  • 5 年 Gopher 都不知道的 defer 細節,你別再掉進坑裡!
    什麼是 deferdefer 是 Go 語言提供的一種用於註冊延遲調用的機制,每一次 defer 都會把函數壓入棧中,當前函數返回前再把延遲函數取出並執行。defer 語句並不會馬上執行,而是會進入一個棧,函數 return 前,會按先進後出(FILO)的順序執行。也就是說最先被定義的 defer 語句最後執行。
  • 關於Golang的4個小秘密
    mainimport ( "fmt" "sync")func main() { in := []int{1, 2, 3} var out []*int wg := sync.WaitGroup{} for _, v := range in { wg.Add(1) go func() { defer