Go 底層探索 -六-: 延遲函數 defer
@注: 以下內容來自《Go 語言底層原理剖析》、《Go 語言設計與實現》書中的摘要信息,本人使用版本 (
Go1.18
) 與書中不一致,源碼路徑可能會有出入。
- 介紹
defer
是Go
語言中的關鍵字,也是Go
語言的重要特性之一。defer
的語法形式爲defer 函數
,其後必須緊跟一個函數調用或者方法調用。在很多時候,defer
後的函數以匿名函數或閉包的形式呈現,例如:
defer func(...){
// 邏輯處理
}
- 特性
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
*/
代碼分析:
-
b = 2: 當函數執行到第一個
defer
時,把a
當參數傳遞defer
函數後參數將預先被固定,此時的結果已經求出b=1+1
,等待輸出。 -
c = 100: 當執行到第二個
defer
時,並沒有把a
傳到defer
函數中, 由於defer
的延遲特性,要等函數結束後才能執行,函數結束後時,a
被賦值爲 99,所以c
計算的結果是99 +1 = 100
- 常見用途
3.1 資源釋放
利用defer
的延遲執行特性,defer
一般用於資源的釋放和異常的捕獲,作爲Go
語言的特性之一,defer
給Go
代碼的書寫方式帶來了很大的變化。下面的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
來實現;
- 返回值陷阱
除了前面提到的參數預計算,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 賦值,第二步再執行 defer,第三步執行空的 return。但是在有名與無名的函數返回值的情況下會有些區別:
4.3 無名函數返回
如果函數的返回值是無名的(不帶命名返回值)如上例中的f2()
,則go
語言會在執行return
指令時, 創建一個臨時變量保存返回值,最後返回。結合f2
理解如下:
// 這裏返回值是無名
func f2() int {
// 第一步:return賦值;創建了一個臨時變量保存返回值
n := 1
// 後續defer對n操作,這裏的n並不是返回值變量
defer func() {
n++
}()
// 空的return,這一步是將第一步中的臨時變量保存的值返回
return
}
a. 分析下代碼運行:
上例代碼一共執行 3 步操作:
-
**return 賦值:**因爲返回值沒有命名,所以
return
默認指定了一個返回值(假設爲s
), 實際運行可以理解如下:n := 1 // 這裏的s指的是臨時變量 s := n
-
defer
操作: 後續的defer
操作都是針對n
進行的,並不會影響返回值s
,所以s
始終都是等於1
. -
空 return 返回:大部分人都會被
return n
給誤導,明明返回的是n
, 爲什麼最後結果不對呢,實際上最後的返回rentun n
最後會變成return
。用代碼理解如下:
// 定義臨時變量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
}
- 數據結構
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
字段串聯成鏈表。
- 執行機制
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
的函數調用:
-
步驟一: 在遇到
ODEFER
節點時會執行Curfn.Func.SetHasDefer(true)
設置當前函數的hasdefer
屬性; 實現代碼位置:cmd/compile/internal/gc.walkstmt
-
步驟二: 執行
s.hasdefer = fn.Func.HasDefer()
更新state
的hasdefer
;實現代碼位置:cmd/compile/internal/gc.buildssa
-
步驟三: 根據
state
的hasdefer
在函數返回之前插入runtime.deferreturn
的函數調用;實現代碼位置:cmd/compile/internal/gc.state.exit
deferproc
和deferreturn
runtime.deferproc
負責創建新的延遲調用;
runtime.deferreturn
負責在函數調用結束時執行所有的延遲調用;
6.2.1 申請_defer
機制
runtime.deferproc
中 runtime.newdefer
的作用是想盡辦法獲得 runtime._defer
結構體,這裏包含三種路徑:
-
從全局緩存池
sched.deferpool
中取出結構體並將該結構體追加到當前邏輯處理器P
局部緩存池中; -
從邏輯處理器
P
局部緩存池P.deferpool
中取出結構體; -
通過
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
關鍵字的方法,它不是在所有的場景下都會開啓的,開放編碼只會在滿足以下的條件時啓用:
-
函數的
defer
數量少於或者等於 8 個; -
函數的
defer
關鍵字不能在循環中執行; -
函數的
return
語句與defer
語句的乘積小於或者等於15
個;
初看上述幾個條件可能會覺得不明所以,但是當我們深入理解基於開放編碼的優化就可以明白上述限制背後的原因。
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