golang defer 的原理和使用

 defer 是 golang 中的一個關鍵字,用於指定函數的延遲執行。正式因爲這個特性 defer 經常用於進行資源的管理和異常捕捉恢復。defer 關鍵字有如下特徵需要再使用時注意:

defer 使用場景

資源管理

   defer 最經常使用的場景就是實現對資源的自動釋放,這一點和 c++ 的 RAII 很類似,如 DB 的連接資源,網絡連接資源、文件句柄資源等等,在申請之後都需要釋放,但是申請之後後續還有很多複雜的處理邏輯如果每個 return 的位置都要進行資源的手動釋放不僅繁瑣而且很容易遺漏,特別是在大型軟件開發中同一個服務多人協作開發模式的場景下。比如在網絡請求時最常見的場景:

    rsp, err := http.Get("http://www.google.com.hk")
    if err != nil {
        fmt.Println(err)
        return err
    }
    if rsp.StatusCode != http.Status_OK {
        rsp.Body.Close()
        return errors.New("http status no ok")
    }
    body, err := ioutil.ReadAll(rsp.Body)
    if err != nil {
        defer rsp.Body.Close()
        return err
    }
    var data DataStruct 
    err = json.UnMarshal(body, &data)
    if err != nil {
        rsp.Body.Close()
        return err
    }
    rsp.Body.Close()
    return nil

這個示例中通過 http 協議向對端發起一個網絡請求之後,需要進行 http 協議返回值的判斷,需要讀取返回數據,對返回數據進行解析。先說一下這個示例中有個新手容易犯的錯誤就是不進行 Close 的調用,在進行網絡、文件等處理中一定要仔細閱讀接口文檔確認是否有資源是需要手動釋放的。回到這個例子,後續步驟的任何一步都可能報錯,如果每一步都要進行 rsp.Body.Close() 的調用則對開發者來講太過繁瑣,容易讓人陷在這些異常處理中而沒有精力關心真正的業務邏輯。而使用 defer 則能大大降低資源釋放的工作量。

    rsp, err := http.Get("http://www.google.com.hk")
    if err != nil {
        fmt.Println(err)
        return err
    }
    defer rsp.Body.Close()
    if rsp.StatusCode != http.Status_OK {
        return errors.New("http status no ok")
    }
    body, err := ioutil.ReadAll(rsp.Body)
    if err != nil {
        return err
    }
    var data DataStruct 
    err = json.UnMarshal(body, &data)
    if err != nil {      
      return err
    }
    return nil

recover 場景

  首先看看 recover 場景的作用。在開發中難免會出現一些惡意數據、內存越界等導致的 panic 問題,如果對這些 panic 問題不進行捕獲 recover 則可能導致程序直接崩潰。爲了保證服務的高可用,對於這些異常一般都會進行捕獲然後盡心 recover 避免單一異常影響整個服務,從而提升服務的可用性。recover 一般會結合 defer 來使用,因爲異常的位置一般是不能預測的,所以一般在函數的開頭就使用 defer 插入 recover 的延遲調用。下面看一個簡單的例子:

func deferRecover() {
   /*defer func() {
      if r := recover(); r != nil {
         fmt.Println(r)
      }
   }()*/
   panic("panic test")
}
func main() {
   fmt.Println("main begin")
   deferRecover()
   fmt.Println("main end")
}

運行結果如下:

main begin
panic: panic test
goroutine 1 [running]:
main.deferRecover(...)
        /Users/abc/project/defer_learn/main.go:9
main.main()
        /Users/abc/project/defer_learn/main.go:14 +0x96
exit status 2

可以看到 panic 的觸發導致了整個程序的異常結束。在互聯網業務中一般要求服務能 7 * 24 提供正常服務,panic 的服務異常重啓肯定會導致服務的短暫不可用,特別是一些有預加載過程的程序可用性影響更爲嚴重,所以一般會通過 defer 結合 recover 來避免異常導致的服務重啓。

func deferRecover() {
   defer func() {
      if r := recover(); r != nil {
         fmt.Println(r)
      }
   }()
   panic("panic test")
}
func main() {
   fmt.Println("main begin")
   deferRecover()
   fmt.Println("main end")
}

運行結果爲:

main begin
panic test
main end

從運行結果來看 panic 沒有導致程序異常退出,通過 defer 結合 recover 提升了服務的可用性。

defer 使用的注意事項

   前面梳理了 defer 在實際開發中的使用場景,下面再來詳細介紹下使用中的幾個注意事項,有些也是我們在使用中經常採坑的地方。

先進後出模式

  首先看看 defer 的執行順序問題,多個 defer 其執行順序是先進後出的堆棧模式。

func deferFILO() {
   defer func(){
      fmt.Println("first defer")
   }()
   defer func(){
      fmt.Println("second defer")
   }()
   fmt.Println("defer filo test")
}
defer filo test
second defer
first defer

從運行結果看 defer 的執行是在函數返回前,所以函數中的打印是排在最前面的,然後後 defer 的函數先執行。

值傳遞

  Golang 的參數傳遞是採用了值傳遞的方式,defer 的傳參也是同樣。雖然 defer 函數的執行是在 return 之前,但是參數傳遞在聲明的地方就已完成,如以下例子:

func deferValue() {
   i := 20
   defer fmt.Println(i)
   i = i+10
}
20

最終輸出的結果是 20 而不是 30,說明雖然 defer 的函數是在 return 前執行的,但是 i 的值卻在 defer 聲明的地方就已經傳遞了。

Golang 的 defer 值傳遞還有一個需要注意的地方,應該是說 go 和 defer 關鍵字都需要注意的地方就是閉包問題以及變量延遲調用的問題。

for i := 0; i < 3; i++ {
   defer func(){
      fmt.Println(i)
   }()
}
3
3
3
type Res struct {
   name string
}
func (r *Res) Close() {
   fmt.Println(r.name)
}
func deferResClosure() {
   ls := []Res{
      {"res1"},
      {"res2"},
      {"res3"},
   }
   for _, r := range ls {
      defer r.Close()
   }
   for _, r := range ls {
      defer func(r *Res) {
         r.Close()
      }(&r)
   }
   for _, r := range ls {
      defer func(r Res) {
         r.Close()
      }(r)
   }
}
res3
res2
res1
res3
res3
res3
res3
res3
res3

這三個場景的輸出結果如果大家都能正確推斷出來,那恭喜你對於 Golang defer 的值傳遞已經理解的不錯了。首先注意 defer 是後入先出的,所以最上面 3 個結果樹最後的循環的結果,在聲明 defer 函數的時候進行了傳值,這個時候 r 分別是 1 2 3,值傳遞之後調用的輸出就是 3 2 1,這個應該是比較直觀的一個結果。第二個輸出爲什麼全是 3 呢,和第一個的區別是什麼?一個是值,一個是指針,結果集 1 和 2 其實就是傳值和傳指針的區別。golang 中只有值傳遞,這個是一定需要銘記的事情,這裏還有一個知識點就是 for-range 循環的原理這裏不做詳細介紹,只是介紹一下等價代碼。

   for _, r := range ls {
      defer func(r *Res) {
         r.Close()
      }(&r)
   }
   // 等價代碼
   var value Foo
   for var i := 0; i < len(ls); i++ {
       value = ls[i]
       defer func(r *Res) {
         r.Close()
       }(&value)
   }

for-range 的指針循環相當於定義了一個初始變量,在循環過程中將數組中的值賦值給了,然後將初始變量的指針傳遞給 defer 函數,所以實際幾個 defer 函數調用的參數都是初始變量的地址,最終 for-range 循環結束後初始變量指向的就是數組的最後一個元素,所以就是這個結果。

最後再說一下爲什麼第一個全部都是 3,因爲 Res 定義的是指針函數,所以雖然看起來是用的值方法,但是實際上還是指針方法的調用。Golang 的文檔中有這麼一句話:

The rule about pointers vs. values for receivers is that value
methods can be invoked on pointers and values, but pointer 
methods can only be invoked on pointers.There is a handy 
exception, though. When the value is addressable, the language
takes care of the common case of invoking a pointer method on a
value by inserting the address operator automatically。

主要意思有兩個:

1. 值方法可以值調用和指針調用,指針方法只能指針調用
2. 如果某個值是左值,則值調用會被插入取地址符號,這樣看起來是指調用,實際使用了指針調用

返回值

  說完了 defer 的參數傳值再來看看 defer 的返回值。defer 對返回值的影響需要注意 defer 的執行順序,通過對 defer 實際執行的拆解能夠幫助我們快速理解。先看幾個典型的例子,猜一下數據結果,然後進行拆解再看是不是很容易就能看出返回值。

// 實例1
func deferNamedReturn() (result int) {
    defer func() {
        result++
    }()
    return 0
}
// 實例2
func deferReturn() (r int) {
     t := 5
     defer func() {
       t = t + 5
     }()
     return t
}

可以先推斷一下,再看答案。結果如下:

1
5

defer 對返回結果的影響其實可以簡單的拆解成 3 個步驟:

  1. 給返回值賦值

  2. 調用 defer 函數

  3. return

按這 3 個步驟來對上面的兩個實例進行代碼拆解:

// 實例1
func deferNamedReturn() (result int) {
    result := 0
    func() { // defer call
        result++
    }()
    return 
}
// 實例2
func deferReturn() (r int) {
     t := 5
     r = t
     func() {
       t = t + 5
     }()
     return
}

拆解之後返回值很容易推斷了,defer 的延遲調用我們一直喊着的是在 return 之前,而實際上應該是返回值賦值之後 return 之前。

defer 的實現原理

   defer 的底層原理這裏不做詳細介紹,大概介紹一下其實現原理。defer 關鍵字對函數執行流程的影響是在插入了指令 call runtime.deferproc,然後在函數返回之前的地方,插入指令 call runtime.deferreturn。call runtime.deferproc 會將延遲函數從頭部插入延遲函數鏈中,然後在 call runtime.deferreturn 中先給函數返回值賦值再按順序從鏈條頭部出列進行順序調用。

                       

defer 對性能的影響

首先需要牢記服務開發過程的兩個基本原則:

defer 對性能有沒有影響?有但是可以忽略。Golang 系統更新也一直在對 defer 關鍵字做優化,1.14 及其以上版本的 defer 性能消耗大概是十幾個 ns 左右,有些場景會更低,所以對於絕大多數場景 defer 關鍵字對程序性能的影響都可以忽略。

高級用法

     前面提到過 defer 和 recover 進行結合使用來提升服務的可用性。但是如果每個服務接口中都做這個事情那對於開發人員來說又成了另外一個噩夢,所以這裏介紹一個簡單卻適用的方法給大家。一般的服務框架都有後 middleware 機制,雖然各個框架的底層實現可能都不一樣但是基本都有該類機制,方便快發人員進行鑑權、限流等等基礎功能的開發,可以通過在 middleware 中增加 defer 和 recover 來實現對所有接口的自動恢復能力。當然 recover 也不是萬能的,部分 panic 是沒有辦法 recover 的,比如 map 的併發訪問。

總結

   defer 關鍵字在實際開發過程中會經常使用到,本文介紹了 defer 關鍵字的特點和詳細使用場景,對使用中需要注意的地方進行了詳細分析,也簡單介紹了 defer 的實現原理和對性能的影響,結合實際開發中的問題也給出了進一步優化使用的高級用法。開發中對於知識一定要掌握用法對使用場景梳理清楚理解透徹基本原理,對於底層實現則可以抓大放小否則很容易陷進去。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/94ZONByFcab0S2TVRVRcNA