2. Go中defer使用注意事項

1. 簡介

defer 會在當前函數返回前執行傳入的函數,它會經常被用於關閉文件描述符、關閉數據庫連接以及解鎖資源。

理解這句話主要在三個方面:

  1. 當前函數
  2. 返回前執行,當然函數可能沒有返回值
  3. 傳入的函數,即 defer 關鍵值後面跟的是一個函數,包括普通函數如(fmt.Println), 也可以是匿名函數 func()

1.1 使用場景

使用 defer 的最常見場景是在函數調用結束後完成一些收尾工作,例如在 defer 中回滾數據庫的事務:

func createPost(db *gorm.DB) error {
    tx := db.Begin()
    // 用來回滾數據庫事件
    defer tx.Rollback()
    
    if err := tx.Create(&Post{Author: "Draveness"}).Error; err != nil {
        return err
    }
    
    return tx.Commit().Error
}

在使用數據庫事務時,我們可以使用上面的代碼在創建事務後就立刻調用 Rollback 保證事務一定會回滾。哪怕事務真的執行成功了,那麼調用 tx.Commit() 之後再執行 tx.Rollback() 也不會影響已經提交的事務。

1.2 注意事項

使用defer時會遇到兩個常見問題,這裏會介紹具體的場景並分析這兩個現象背後的設計原理:

  • defer 關鍵字的調用時機以及多次調用 defer 時執行順序是如何確定的
  • defer 關鍵字使用傳值的方式傳遞參數時會進行預計算,導致不符合預期的結果

作用域

向 defer 關鍵字傳入的函數會在函數返回之前運行。

假設我們在 for 循環中多次調用 defer 關鍵字:

package main

import "fmt"

func main() {
	for i := 0; i < 5; i++ {
	    // FILO, 先進後出, 先出現的關鍵字defer會被壓入棧底,會最後取出執行
		defer fmt.Println(i)
	}
}

#運行
$ go run main.go
4
3
2
1
0

運行上述代碼會倒序執行傳入 defer 關鍵字的所有表達式,因爲最後一次調用 defer 時傳入了 fmt.Println(4),所以這段代碼會優先打印 4。我們可以通過下面這個簡單例子強化對 defer 執行時機的理解:

package main

import "fmt"

func main() {
    // 代碼塊
	{
		defer fmt.Println("defer runs")
		fmt.Println("block ends")
	}

	fmt.Println("main ends")
}
# 輸出
$ go run main.go
block ends
main ends
defer runs

從上述代碼的輸出我們會發現,defer 傳入的函數不是在退出代碼塊的作用域時執行的,它只會在當前函數和方法返回之前被調用。

預計算參數

Go 語言中所有的函數調用都是傳值的.

雖然 defer 是關鍵字,但是也繼承了這個特性。假設我們想要計算 main 函數運行的時間,可能會寫出以下的代碼:

package main

import (
	"fmt"
	"time"
)

func main() {
	startedAt := time.Now()
	// 這裏誤以爲:startedAt是在time.Sleep之後纔會將參數傳遞給defer所在語句的函數中
	defer fmt.Println(time.Since(startedAt))

	time.Sleep(time.Second)
}
# 輸出
$ go run main.go
0s

上述代碼的運行結果並不符合我們的預期,這個現象背後的原因是什麼呢?

經過分析(或者使用debug方式),我們會發現:

  1. 調用 defer 關鍵字會立刻拷貝函數中引用的外部參數

所以 time.Since(startedAt) 的結果不是在 main 函數退出之前計算的,而是在 defer 關鍵字調用時計算的,最終導致上述代碼輸出 0s。

想要解決這個問題的方法非常簡單,我們只需要向 defer 關鍵字傳入匿名函數:

package main

import (
	"fmt"
	"time"
)

func main() {
	startedAt := time.Now()
    // 使用匿名函數,傳遞的是函數的指針
	defer func() {
		fmt.Println(time.Since(startedAt))
	}()

	time.Sleep(time.Second)
}
#輸出
$ go run main.go
$ 1.0056135s

2. defer 數據結構

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

type _defer struct {
	siz       int32
	started   bool
	openDefer bool
	sp        uintptr
	pc        uintptr
	fn        *funcval
	_panic    *_panic
	link      *_defer
}

簡單介紹一下 runtime._defer 結構體中的幾個字段:

  • siz 是參數和結果的內存大小;
  • sp 和 pc 分別代表棧指針和調用方的程序計數器;
  • fn 是 defer 關鍵字中傳入的函數;
  • _panic 是觸發延遲調用的結構體,可能爲空;
  • openDefer 表示當前 defer 是否經過開放編碼的優化;

除了上述的這些字段之外,runtime._defer 中還包含一些垃圾回收機制使用的字段, 這裏不做過多的說明

3. 執行機制

堆分配、棧分配和開放編碼是處理 defer 關鍵字的三種方法。

  1. 早期的 Go 語言會在堆上分配, 不過性能較差
  2. Go 語言在 1.13 中引入棧上分配的結構體,減少了 30% 的額外開銷
  3. 在 1.14 中引入了基於開放編碼的 defer,使得該關鍵字的額外開銷可以忽略不計

堆上分配暫時不做過多的說明

3.1 棧上分配

在 1.13 中對 defer 關鍵字進行了優化,當該關鍵字在函數體中最多執行一次時,會將結構體分配到棧上並調用。

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

3.2 開放編碼

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

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

  1. 函數的 defer 數量小於或等於8個;
  2. 函數的 defer 關鍵字不能再循環中執行
  3. 函數的 return 語句 與 defer 語句個數的成績小於或者等於15個。

4. 參考

  1. https://draveness.me/golang/docs/part2-foundation/ch05-keyword/golang-defer/
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章