文章來自微信公眾號:Go語言圈
1 簡介
defer會在當前函式返回前執行傳入的函式,它會經常被用於關閉檔案描述符、關閉資料庫連線以及解鎖資源。
理解這句話主要在三個方面:
- 當前函式
- 返回前執行,當然函式可能沒有返回值
- 傳入的函式,即
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
方式),我們會發現:
呼叫 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)
}
//輸出
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 關鍵字的三種方法。
早期的 Go 語言會在堆上分配, 不過效能較差
Go 語言在 1.13 中引入棧上分配的結構體,減少了 30% 的額外開銷
在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
關鍵字的方法,它不是在所有的場景下都會開啟的,開放編碼只會在滿足以下的條件時啟用:
函式的 defer
數量小於或等於8個;
函式的 defer
關鍵字不能再迴圈中執行
函式的 return
語句 與 defer
語句個數的成績小於或者等於15個。
本作品採用《CC 協議》,轉載必須註明作者和本文連結