Go:淺談defer

前言:
最近在看《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函數的參數和變量問題

先上結論,然後在一個個分析:

  1. 參數是函數的返回值,那麼會優先執行該參數函數
  2. 閉包中的變量。如果是在主體函數中的局部變量,那麼一定要小心使用。因爲在執行在被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的內存分配一開始是堆,但是經過不斷的優化之後有三種模式,具體選用哪種分配模式要根據實際代碼和編譯的的選擇。
由於本人水平還很有限,就不進行過多深入,怕誤導大家,至於哪三種模式的總結我將引用坤神的文章

  1. 對於開放編碼式 defer 而言:
    編譯器會直接將所需的參數進行存儲,並在返回語句的末尾插入被延遲的調用;
    當整個調用中邏輯上會執行的 defer 不超過 15 個(例如七個 defer 作用在兩個返回語句)、總 defer 數量不超過 8 個、且沒有出現在循環語句中時,會激活使用此類 defer;
    此類 defer 的唯一的運行時成本就是存儲參與延遲調用的相關信息,運行時性能最好。
  2. 對於棧上分配的 defer 而言:
    編譯器會直接在棧上記錄一個 _defer 記錄,該記錄不涉及內存分配,並將其作爲參數,傳入被翻譯爲 deferprocStack 的延遲語句,在延遲調用的位置將 _defer 壓入 Goroutine 對應的延遲調用鏈表中;
    在函數末尾處,通過編譯器的配合,在調用被 defer 的函數前,調用 deferreturn,將被延遲的調用出棧並執行;
    此類 defer 的唯一運行時成本是從 _defer 記錄中將參數複製出,以及從延遲調用記錄鏈表出棧的成本,運行時性能其次。
  3. 對於堆上分配的 defer 而言:
    編譯器首先會將延遲語句翻譯爲一個 deferproc 調用,進而從運行時分配一個用於記錄被延遲調用的 _defer 記錄,並將被延遲的調用的入口地址及其參數複製保存,入棧到 Goroutine 對應的延遲調用鏈表中;
    在函數末尾處,通過編譯器的配合,在調用被 defer 的函數前,調用 deferreturn,從而將 _defer 實例歸還到資源池,而後通過模擬尾遞歸的方式來對需要 defer 的函數進行調用。
    此類 defer 的主要性能問題存在於每個 defer 語句產生記錄時的內存分配,記錄參數和完成調用時的參數移動時的系統調用,運行時性能最差。

5. 小結

  1. 使用defer會產生一定的開銷,雖然已經進行的很多優化,但是還是儘量不要使用
  2. 注意閉包和函數作爲參數的問題

參考文獻:
golang defer原理
坤神文章

撩我?
可以搜索我的公衆號:Kyda
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章