Go 底層探索 -六-: 延遲函數 defer

@注: 以下內容來自《Go 語言底層原理剖析》、《Go 語言設計與實現》書中的摘要信息,本人使用版本 (Go1.18) 與書中不一致,源碼路徑可能會有出入。

  1. 介紹

deferGo語言中的關鍵字,也是Go語言的重要特性之一。defer的語法形式爲defer 函數,其後必須緊跟一個函數調用或者方法調用。在很多時候,defer後的函數以匿名函數或閉包的形式呈現,例如:

defer func(...){
  // 邏輯處理
}
  1. 特性

2.1 延遲執行

defer後的函數並不會立即執行,而是推遲到了函數結束後執行,示例如下:

func deferDelayExec() {
 defer func() {
  fmt.Println("World")
 }()
 fmt.Println("Hello ")
}
/** 輸出
Hello 
World
*/

2.2 先進後出

在函數體內部,可能出現多個defer函數。這些defer函數將按照後入先出(last-in first-out,LIFO)的順序執行,這與棧的執行順序是相同的。

func deferExecSort() {
 for i := 1; i <= 5; i++ {
  defer fmt.Println(i)
 }
}
/**輸出
5
4
3
2
1
*/

2.3 參數預計算

defer的另一個特性是參數的預計算,這一特性時常導致開發者在使用defer時犯錯。因爲在大部分時候,我們記住的都是其延遲執行的特性。參數的預計算指當函數到達 defer 語句時,延遲調用的參數將立即求值,傳遞到 defer 函數中的參數將預先被固定,而不會等到函數執行完成後再傳遞參數到 defer 中。看下面代碼示例:

func deferParam() {
 a := 1
 // 參數a傳入
 defer func(b int) {
  fmt.Println("b = ", b+1)
 }(a)
 defer func() {
  fmt.Println("c = ", a+1)
 }()
 a = 99
 fmt.Println("a = ", a)
}
/**輸出
a =  99
c =  100
b =  2
*/

代碼分析:

  1. 常見用途

3.1 資源釋放

利用defer的延遲執行特性,defer一般用於資源的釋放和異常的捕獲,作爲Go語言的特性之一,deferGo代碼的書寫方式帶來了很大的變化。下面的CopyFile函數用於將文件srcName的內容複製到文件dstName中。

func CopyFile(srcName string, dstName string) error {
    // 打開源文件
    srcFile, err := os.Open(srcName)
    if err != nil {
        return err
    }
    // 釋放資源
    defer srcFile.Close()

    // 創建目標文件
    dstFile, err := os.Create(dstName)
    if err != nil {
        return err
    }
    // 釋放資源
    defer dstFile.Close()

    // 複製文件內容
    _, err = io.Copy(dstFile, srcFile)
    return err
}

除了上面常見的操作文件資源以外,defer還常用於鎖和鎖的釋放,實例代碼如下:

// 不使用defer時
func lockNoUseDefer() {
 ...
 p.Lock()
 if p.count < 10 {
    // 釋放鎖
  p.Unlock()
  return p.count
 }
 p.count++
 newCount := p.count
  // 釋放鎖
 p.Unlock()
 return newCount
}

// 使用defer時
func lockUseDefer()  {
 ...
 p.Lock()
  // 使用defer釋放鎖
 defer p.Unlock()
 if p.count < 10 {
  return p.count
 }
 p.count++
 return p.count
}

通過上面代碼可以看出, 使用defer後, 代碼的可讀性更好,而且不會因爲邏輯複雜而忘了解鎖,導致死鎖的情況。

3.2 異常捕獲

func deferCatchError() {
 // 定義一個匿名延遲函數
 defer func() {
  err := recover()
  msg := fmt.Sprintf("err信息: %v",err)
  if err != nil {
   // 程序觸發panic時,會被這裏捕獲
   fmt.Println(msg)
  }
 }()
 // 故意拋出panic
 panic("這裏出錯了~" )
}

/** 輸出
  err信息: 這裏出錯了~
*/

程序在運行時, 任意地方都可能會發生panic異常,例如算術除 0 錯誤、內存無效訪問、數組越界等,一旦發生panic異常, 就會導致整個程序異常退出,這不是我們想見到的結果,通常我們希望程序能夠繼續正常執行,其他編程語言會提供try..catch的語法,但go不支持try..catch的語法, 只能通過defer + recover來實現;

  1. 返回值陷阱

除了前面提到的參數預計算,defer還有一種非常容易犯錯的場景: 涉及與返回值參數結合。

4.1 先看示例

func f1() (n int) {
 n = 1
 defer func() {
  n++
 }()
 return n
}

func f2() int {
 n := 1
 defer func() {
  n++
 }()
 return n
}

func TestRun(t *testing.T) {
 fmt.Println("f1 = ", f1())
 fmt.Println("f2 = ", f2())
}

運行輸出如下:

=== RUN   TestRun
f1 =  2
f2 =  1
--- PASS: TestRun (0.00s)
PASS

4.2 defer、return、返回值

在講三者的執行順序前,先了解下return返回值的運行機制,return xx並非原子操作,在編譯後會被分爲賦值返回值兩個指令。

當與defer結合使用時,三者的執行順序:

所以結論是:第一步先 return 賦值,第二步再執行 defer,第三步執行空的 return。但是在有名與無名的函數返回值的情況下會有些區別:

4.3 無名函數返回

如果函數的返回值是無名的(不帶命名返回值)如上例中的f2(),則go語言會在執行return指令時, 創建一個臨時變量保存返回值,最後返回。結合f2理解如下:

// 這裏返回值是無名
func f2() int {
  // 第一步:return賦值;創建了一個臨時變量保存返回值
 n := 1
  // 後續defer對n操作,這裏的n並不是返回值變量
 defer func() {
  n++
 }()
  // 空的return,這一步是將第一步中的臨時變量保存的值返回
 return 
}

a. 分析下代碼運行:

上例代碼一共執行 3 步操作:

// 定義臨時變量s,s是最終返回值
var s int 
// 賦值
n := 1
s = n
return s

4.4 有名函數返回

有名返回值的函數, 由於返回值變量已經提前定義,所以運行過程中並不會再創建臨時變量,後續defer操作的變量都是返回值變量, 結合f1理解如下:

// 定義了返回值變量
func f1() (n int) {
  // 直接操作返回值
 n = 1
 defer func() {
  // 這裏操作的也是返回值
  n++
 }()
 return n
}
  1. 數據結構

5.1 字段釋義

defer 關鍵字在 Go 語言源代碼中對應的數據結構如下:

type _defer struct {
 siz       int32 // 參數和結果的內存大小
 started   bool
 openDefer bool //表示當前 defer 是否經過開放編碼的優化
 sp        uintptr //棧指針
 pc        uintptr //調用方的程序計數器
 fn        *funcval //defer 關鍵字中傳入的函數
 _panic    *_panic // 觸發延遲調用的結構體
 link      *_defer // 使用此字段串成鏈表
  ...
}

5.2 串成鏈表

runtime._defer 結構體是延遲調用鏈表上的一個元素,所有的結構體都會通過 link 字段串聯成鏈表。

串聯成鏈示意圖

  1. 執行機制

6.1 三種機制

中間代碼生成階段的 cmd/compile/internal/gc.state.stmt 會負責處理程序中的 defer,該函數會根據條件的不同,使用三種不同的機制處理該關鍵字:

func (s *state) stmt(n *Node) {
 ...
 switch n.Op {
    ...
 case ODEFER:
  if s.hasOpenDefers {
   s.openDeferRecord(n.Left) // 開放編碼
  } else {
   d := callDefer // 堆分配
   if n.Esc == EscNever {
    d = callDeferStack // 棧分配
   }
   s.callResult(n.Left, d)
  }
 }
}

堆分配、棧分配和開放編碼是處理 defer 關鍵字的三種機制,早期的 Go 語言會在堆上分配 runtime._defer 結構體,不過該實現的性能較差,Go 語言在 1.13 中引入棧上分配的結構體,減少了 30% 的額外開銷,並在 1.14 中引入了基於開放編碼的 defer,使得該關鍵字的額外開銷可以忽略不計。

6.2 堆分配

從上述源碼可以看出: 堆分配是默認的兜底執行方案,當該方案被啓用時,編譯器不僅將 defer 關鍵字都轉換成 runtime.deferproc 函數,它還會通過以下三個步驟爲所有調用 defer 的函數末尾插入 runtime.deferreturn 的函數調用:

deferprocdeferreturn

  • runtime.deferproc 負責創建新的延遲調用;

  • runtime.deferreturn 負責在函數調用結束時執行所有的延遲調用;

6.2.1 申請_defer機制

runtime.deferprocruntime.newdefer 的作用是想盡辦法獲得 runtime._defer 結構體,這裏包含三種路徑:

  1. 從全局緩存池 sched.deferpool 中取出結構體並將該結構體追加到當前邏輯處理器P局部緩存池中;

  2. 從邏輯處理器P局部緩存池 P.deferpool 中取出結構體;

  3. 通過 runtime.mallocgc 在堆上創建一個新的結構體;

  • defer執行完畢被銷燬後,會重新回到局部緩存池中;

  • 當局部緩存池容納了足夠的對象時,會將_defer結構體放入全局緩存池。

  • 存儲在全局和局部緩存池中的對象如果沒有被使用,則最終在垃圾回收階段被銷燬。

6.3 棧分配

defer堆分配的過程可以看出,即便有全局和局部緩存池策略,由於涉及堆與棧參數的複製等操作,堆分配仍然比直接調用效率低下。

Go 語言團隊在 1.13 中對 defer 關鍵字進行了優化,當該關鍵字在函數體中最多執行一次時,編譯期間的 cmd/compile/internal/gc.state.call 會將結構體分配到棧上並調用 runtime.deferprocStack

因爲在編譯期間我們已經創建了 runtime._defer 結構體,所以在運行期間 runtime.deferprocStack 只需要設置一些未在編譯期間初始化的字段,就可以將棧上的 runtime._defer 追加到函數的鏈表上:

func deferprocStack(d *_defer) {
 gp := getg()
 d.started = false
 d.heap = false // 棧上分配的 _defer
 d.openDefer = false
 d.sp = getcallersp()
 d.pc = getcallerpc()
 d.framepc = 0
 d.varp = 0
 *(*uintptr)(unsafe.Pointer(&d._panic)) = 0
 *(*uintptr)(unsafe.Pointer(&d.fd)) = 0
 *(*uintptr)(unsafe.Pointer(&d.link)) = uintptr(unsafe.Pointer(gp._defer))
 *(*uintptr)(unsafe.Pointer(&gp._defer)) = uintptr(unsafe.Pointer(d))

 return0()
}

除了分配位置的不同,棧上分配和堆上分配的 runtime._defer 並沒有本質的不同,而該方法可以適用於絕大多數的場景,與堆上分配的 runtime._defer 相比,該方法可以將 defer 關鍵字的額外開銷降低大約 30%。

6.4 開放編碼

Go 語言在 1.14 中通過開放編碼(Open Coded)實現 defer 關鍵字,該設計使用代碼內聯優化 defer 關鍵的額外開銷並引入函數數據 funcdata 管理 panic 的調用,該優化可以將 defer 的調用開銷從 1.13 版本的 ~35ns 降低至 ~6ns 左右:

With normal (stack-allocated) defers only:         35.4  ns/op
With open-coded defers:                             5.6  ns/op
Cost of function call alone (remove defer keyword): 4.4  ns/op

然而開放編碼作爲一種優化 defer 關鍵字的方法,它不是在所有的場景下都會開啓的,開放編碼只會在滿足以下的條件時啓用:

初看上述幾個條件可能會覺得不明所以,但是當我們深入理解基於開放編碼的優化就可以明白上述限制背後的原因。

6.4.1  defer <=8

Go 語言會在編譯期間就確定是否啓用開放編碼,在編譯器生成中間代碼之前,我們會使用 cmd/compile/internal/gc.walkstmt 修改已經生成的抽象語法樹,設置函數體上的 OpenCodedDeferDisallowed 屬性:

const maxOpenDefers = 8

func walkstmt(n *Node) *Node {
 switch n.Op {
 case ODEFER:
  Curfn.Func.SetHasDefer(true)
  Curfn.Func.numDefers++
    //數量>8個禁用開放編碼優化
  if Curfn.Func.numDefers > maxOpenDefers {
   Curfn.Func.SetOpenCodedDeferDisallowed(true)
  }
  if n.Esc != EscNever {
      //defer處於for循環中,會禁用開放編碼優化
   Curfn.Func.SetOpenCodedDeferDisallowed(true)
  }
  fallthrough
 ...
 }
}

通過上述源碼可以發現: 如果函數中 defer 關鍵字的數量多於 8 個或者 defer 關鍵字處於 for 循環中,那麼我們在這裏都會禁用開放編碼優化

6.4.2 retun語句 * defer < 15

SSA 中間代碼生成階段的 cmd/compile/internal/gc.buildssa 中,我們也能夠看到啓用開放編碼優化的其他條件,也就是返回語句的數量與 defer 數量的乘積需要小於 15:

func buildssa(fn *Node, worker int) *ssa.Func {
 ...
 s.hasOpenDefers = s.hasdefer && !s.curfn.Func.OpenCodedDeferDisallowed()
 ...
 if s.hasOpenDefers &&
  s.curfn.Func.numReturns*s.curfn.Func.numDefers > 15 {
    // 如果大於15 決定當前函數是否應該使用開放編碼優化 defer 關鍵字
  s.hasOpenDefers = false
 }
 ...
}
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/DBW1ICWbATN59X5M91tBFg