1. 程式人生 > >【轉】golang-defer坑的本質

【轉】golang-defer坑的本質

get 改變 在定義函數時 寄存器 局部變量 target var 理由 作者

本文節選自https://tiancaiamao.gitbooks.io/go-internals/content/zh/03.4.html

作者的分析非常透徹,從問題本質分析,就不會對defer產生的副作用產生迷茫。

defer坑的本質是:本質原因是return xxx語句並不是一條原子指令,defer被插入到了賦值 與 ret之間,因此可能有機會改變最終的返回值。

defer使用時的坑

先來看看幾個例子。例1:

func f() (result int) {
    defer func() {
        result++
    }()
    return 0
}

例2:

func f() (r int) {
     t := 5
     defer func() {
       t = t + 5
     }()
     return t
}

例3:

func f() (r int) {
    defer func(r int) {
          r = r + 5
    }(r)
    return 1
}

請讀者先不要運行代碼,在心裏跑一遍結果,然後去驗證。

例1的正確答案不是0,例2的正確答案不是10,如果例3的正確答案不是6......

defer是在return之前執行的。這個在 官方文檔中是明確說明了的。要使用defer時不踩坑,最重要的一點就是要明白,return xxx這一條語句並不是一條原子指令!

函數返回的過程是這樣的:先給返回值賦值,然後調用defer表達式,最後才是返回到調用函數中。

defer表達式可能會在設置函數返回值之後,在返回到調用函數之前,修改返回值,使最終的函數返回值與你想象的不一致。

其實使用defer時,用一個簡單的轉換規則改寫一下,就不會迷糊了。改寫規則是將return語句拆成兩句寫,return xxx會被改寫成:

返回值 = xxx
調用defer函數
空的return

先看例1,它可以改寫成這樣:

func f() (result int) {
     result = 0  //return語句不是一條原子調用,return xxx其實是賦值+ret指令
     func() { //defer被插入到return之前執行,也就是賦返回值和ret指令之間
         result++
     }()
     return
}

所以這個返回值是1。

再看例2,它可以改寫成這樣:

func f() (r int) {
     t := 5
     r = t //賦值指令
     func() {        //defer被插入到賦值與返回之間執行,這個例子中返回值r沒被修改過
         t = t + 5
     }
     return        //空的return指令
}

所以這個的結果是5。

最後看例3,它改寫後變成:

func f() (r int) {
     r = 1  //給返回值賦值
     func(r int) {        //這裏改的r是傳值傳進去的r,不會改變要返回的那個r值
          r = r + 5
     }(r)
     return        //空的return
}

所以這個例子的結果是1。

defer確實是在return之前調用的。但表現形式上卻可能不像。本質原因是return xxx語句並不是一條原子指令,defer被插入到了賦值 與 ret之間,因此可能有機會改變最終的返回值。

defer的實現

defer關鍵字的實現跟go關鍵字很類似,不同的是它調用的是runtime.deferproc而不是runtime.newproc。

在defer出現的地方,插入了指令call runtime.deferproc,然後在函數返回之前的地方,插入指令call runtime.deferreturn。

普通的函數返回時,匯編代碼類似:

add xx SP
return

如果其中包含了defer語句,則匯編代碼是:

call runtime.deferreturn,
add xx SP
return

goroutine的控制結構中,有一張表記錄defer,調用runtime.deferproc時會將需要defer的表達式記錄在表中,而在調用runtime.deferreturn的時候,則會依次從defer表中出棧並執行。

------------------------------------------------

我在補充一下,go 的函數 帶參數名的返回值和不帶參數名的函數返回值對defer是有影響的。

如果defer 操作的是帶參數的函數返回值的參數名,則會直接影響到函數的返回值;

如果defer操作的是函數內部的一個局部變量,這不會影響到函數的返回值;

原因很簡單,return會做兩個事情,

1.拷貝返回值到函數的返回值內存區,有如下幾個場景:

  • a.如果是函數的返回值帶參數名(假設參數名為a),實際在函數內部對a的操作是直接操作這個返回值的內存區域;

如果函數返回直接調用return,不帶任何返回值, 則不會有拷貝過程。此時defer如果沒有顯示的操作a,則不會影響到函數的返回值。

如果函數返回直接調用return X,則會有個a=x的拷貝過程,此時defer如果沒有顯示的操作a,也不會影響到函數的返回值(即使操作了x也不會影響結果。

  • b如果是函數的返回值不帶參名,函數的返回值需要一個顯示的return x語句,此時會有一個拷貝過程,就是將x的值拷貝到返回值的內存區域,此時defer操作x 不會有什麽副作用,此時存放返回值的內存區域是個匿名區域,從源程序的角度看defer直接操作變量是不可能訪問到這個區域。可見不帶形參的返回是避免defer副作用的最有效手段。

2.執行RET指令,執行跳轉。

talk is cheep ,show me the code , 上一段代碼:

[csharp] view plain copy
  1. package main
  2. import (
  3. "fmt"
  4. )
  5. //f1 f2 f3 函數返回值帶有參數名
  6. func f1() (r int) {
  7. defer func() {
  8. r++
  9. }()
  10. return 0
  11. }
  12. func f2() (r int) {
  13. defer func(i int) {
  14. i++
  15. }(r)
  16. return 0
  17. }
  18. func f3() (r int) {
  19. t := 0
  20. defer func() {
  21. t++
  22. }()
  23. return t
  24. }
  25. //f4 f5 函數的返回值都不帶參數名,只有類型
  26. func f4() int {
  27. r := 0
  28. defer func() {
  29. r++
  30. }()
  31. return r
  32. }
  33. func f5() int {
  34. r := 0
  35. defer func(i int) {
  36. i++
  37. }(r)
  38. return 0
  39. }
  40. func main() {
  41. var v int = -1
  42. v = f1()
  43. fmt.Println("v=", v)
  44. v = f2()
  45. fmt.Println("v=", v)
  46. v = f3()
  47. fmt.Println("v=", v)
  48. v = f4()
  49. fmt.Println("v=", v)
  50. v = f5()
  51. fmt.Println("v=", v)
  52. }

結果:

[csharp] view plain copy
  1. v= 1
  2. v= 0
  3. v= 0
  4. v= 0
  5. v= 0

結論:為了避免defer可能引發的歧義,在定義函數時,最好使用不帶參數名的方式。

引申一下,我們來看下go函數的棧幀結構,首先學習下幾個寄存器

  1. FP 棧底寄存器,指向一個函數棧的頂部;
  2. PC 程序計數器,指向下一條執行指令;
  3. SB 指向靜態數據的基指針,全局符號;
  4. SP 棧頂寄存器;
技術分享圖片

1.go的函數調用現場清理由主調函數負責維護; 2.函數的返回地址也在主調函數的棧上開辟; 3.棧上的數據是通過FB+- 偏移量來操作; 4.return語句分兩部分執行: 把返回值拷貝到返回值的棧上空間內 調用RET指令執行函數調用後的下一條指令 但是return的語句不是原子的,defer語句的執行被插入在這兩條指令執行之間,這是導致defer導致歧義的最根本原因。為避免困惑, defer 不應該操作函數返回值存放的區域,即defer語句裏面不應該有對命名返回值參數的操作。 1.go的函數調用現場清理由被掉函數負責維護, 返回的地址也在棧上開辟,通過FB+- 偏移量來操作棧上的數據。

【轉】golang-defer坑的本質