前言:
最近在看《effetive go》看到defer,由於我平時沒怎麼用過defer,之前學得又給忘了,看到一道題試着自己推導一下,發現推導錯了,所以重新好好再總結一下。作者屬於菜雞級別,所以本文還不會涉及到原理層面,文章的題目也是淺談。
1. 需求分析
對於某些需要釋放資源的函數,引入defer是必要的。比如打開文件,對這個文件進行讀寫,在函數的最後對文件在進行關閉,釋放資源。但是函數一長,程序員就容易忘記在函數最後對資源進行釋放,便會爲程序埋下雷。那麼就引入一種延遲機制,使得一些操作可以在一開始就被定義,但是可以等到函數末尾再去執行。Go實現的這種機制就是defer。
2. 特性
特性將會分爲四個部分:延遲特性,後進先出,作用域以及錯誤處理四個方面來講。通過這四個特性可以瞭解對於defer到底該如何使用。
2.1. 延遲特性
在需求分析中也說得很清楚,被defer的函數會等到函數的最後運行。
func test() {
fmt.Println("hello in test")
}
func main() {
defer test()
fmt.Println("hello in main")
}
結果:
hello in main
hello in test
2.2. 後進先出
一個函數中可能會出現多個defer,那麼對於多個defer,採取的是後進先出的策略,也就是按照被defer的順序,從後往前執行,最後defer的函數最先運行。(**提醒:**這並不意味着defer是用棧實現的,實際上其實現很複雜,有堆,棧以及開放源碼)
func main() {
for i:=0; i<5; i++ {
defer test(strconv.Itoa(i))
}
fmt.Println("hello in main")
}
結果如下:
hello in main
hello in test4
hello in test3
hello in test2
hello in test1
hello in test0
2.3. 錯誤處理
用於處理panic,使得panic之後程序能夠繼續進行。
先看一下代碼:
func A() {
fmt.Println("A")
}
func B() {
fmt.Println("B")
panic("panic in B")
}
func C() {
fmt.Println("C")
}
func main() {
A()
B()
C()
}
上述代碼執行結果:
A
B
panic: panic in B
可以看到函數C並沒有被執行。
但是,如果我們可以預知這種錯誤,並且能夠在運行過程中處理這種錯誤,使其不會影響程序的運行,然後保證整體程序的運行,那麼可以使用defer以及rcover配合來恢復程序的。
這也說明defer不受錯誤的影響,只要在引發錯誤的地方之前聲明瞭defer函數,那麼該函數即使在後面還出現錯誤的情況下依然會繼續運行。記住一定要在panic之前聲明。
對於上面的B函數,可以修改爲:
func B() {
fmt.Println("B")
defer func() {
fmt.Println("Func in B")
}()
panic("panic in B")
}
修改後的執行結果:
A
B
Func in B
panic: panic in B
2.4. 作用域
defer的作用域一般只在一個函數體之內
func main() {
func() {
defer fmt.Println("in subFunc")
}()
fmt.Println("in main")
}
結果:
in subFunc
in main
3. 被defer函數的參數和變量問題
先上結論,然後在一個個分析:
- 參數是函數的返回值,那麼會優先執行該參數函數
- 閉包中的變量。如果是在主體函數中的局部變量,那麼一定要小心使用。因爲在執行在被defer的函數中,使用外部變量是使用外部變量的最後一個狀態。
3.1. 參數是函數的返回值
如果被defer函數使用參數是函數的返回值,那麼會優先執行該參數函數。
手動寫下下面代碼的執行結果,看看自己能不能寫對:
func trace(s string) string {
fmt.Println("entering:", s)
return s
}
func un(s string) {
fmt.Println("leaving:", s)
}
func a() {
defer un(trace("a"))
fmt.Println("in a")
}
func b() {
defer un(trace("b"))
fmt.Println("in b")
a()
}
func main() {
b()
}
結果:
entering: b
in b
entering: a
in a
leaving: a
leaving: b
簡單說下:main
調用了b
函數。而b
首先defer了一個un
函數,這個un
函數的參數是一個trace
函數,所以即便這個un
函數會在b函數最後才調用,也會優先執行trace
函數。
3.2. 形成閉包的defer函數
這個也是個大坑,需要多加小心。其主要原理就是形成閉包的defer函數,在被調用的時候使用的外部變量是該變量的最後狀態。
將上面2.2
節中的代碼改寫爲下面形式:
func main() {
for i:=0; i<5; i++ {
defer func() {
fmt.Println("hello in test" + strconv.Itoa(i))
}()
}
fmt.Println("in main")
}
結果如下:
in main
hello in test5
hello in test5
hello in test5
hello in test5
hello in test5
當然可以修改一下,就好了:
func main() {
for i:=0; i<5; i++ {
defer func(i int) {
fmt.Println("hello in test" + strconv.Itoa(i))
}(i)
}
fmt.Println("in main")
}
上面主要原因是把一個閉包給解除掉了,使得defered函數不在使用外部變量,而是使用傳遞進來的參數,這樣不再是一個閉包。
4. 內部原理淺析
Go的多個被defer函數的調用關係是後進先出。但這不意味着defer的內存分配是棧區,千萬不要混淆。
實際上defer的內存分配一開始是堆,但是經過不斷的優化之後有三種模式,具體選用哪種分配模式要根據實際代碼和編譯的的選擇。
由於本人水平還很有限,就不進行過多深入,怕誤導大家,至於哪三種模式的總結我將引用坤神的文章:
- 對於開放編碼式 defer 而言:
編譯器會直接將所需的參數進行存儲,並在返回語句的末尾插入被延遲的調用;
當整個調用中邏輯上會執行的 defer 不超過 15 個(例如七個 defer 作用在兩個返回語句)、總 defer 數量不超過 8 個、且沒有出現在循環語句中時,會激活使用此類 defer;
此類 defer 的唯一的運行時成本就是存儲參與延遲調用的相關信息,運行時性能最好。- 對於棧上分配的 defer 而言:
編譯器會直接在棧上記錄一個 _defer 記錄,該記錄不涉及內存分配,並將其作爲參數,傳入被翻譯爲 deferprocStack 的延遲語句,在延遲調用的位置將 _defer 壓入 Goroutine 對應的延遲調用鏈表中;
在函數末尾處,通過編譯器的配合,在調用被 defer 的函數前,調用 deferreturn,將被延遲的調用出棧並執行;
此類 defer 的唯一運行時成本是從 _defer 記錄中將參數複製出,以及從延遲調用記錄鏈表出棧的成本,運行時性能其次。- 對於堆上分配的 defer 而言:
編譯器首先會將延遲語句翻譯爲一個 deferproc 調用,進而從運行時分配一個用於記錄被延遲調用的 _defer 記錄,並將被延遲的調用的入口地址及其參數複製保存,入棧到 Goroutine 對應的延遲調用鏈表中;
在函數末尾處,通過編譯器的配合,在調用被 defer 的函數前,調用 deferreturn,從而將 _defer 實例歸還到資源池,而後通過模擬尾遞歸的方式來對需要 defer 的函數進行調用。
此類 defer 的主要性能問題存在於每個 defer 語句產生記錄時的內存分配,記錄參數和完成調用時的參數移動時的系統調用,運行時性能最差。
5. 小結
- 使用defer會產生一定的開銷,雖然已經進行的很多優化,但是還是儘量不要使用
- 注意閉包和函數作爲參數的問題
參考文獻:
golang defer原理
坤神文章
撩我?
可以搜索我的公衆號:Kyda