Golang之輕鬆化解defer的溫柔陷阱

碼洞發表於2019-02-14

什麼是defer?

defer是Go語言提供的一種用於註冊延遲呼叫的機制:讓函式或語句可以在當前函式執行完畢後(包括通過return正常結束或者panic導致的異常結束)執行。

defer語句通常用於一些成對操作的場景:開啟連線/關閉連線;加鎖/釋放鎖;開啟檔案/關閉檔案等。

defer在一些需要回收資源的場景非常有用,可以很方便地在函式結束前做一些清理操作。在開啟資源語句的下一行,直接一句defer就可以在函式返回前關閉資源,可謂相當優雅。

f, _ := os.Open("defer.txt")
defer f.Close()
複製程式碼

注意:以上程式碼,忽略了err, 實際上應該先判斷是否出錯,如果出錯了,直接return. 接著再判斷f是否為空,如果f為空,就不能呼叫f.Close()函式了,會直接panic的。

為什麼需要defer?

程式設計師在程式設計的時候,經常需要開啟一些資源,比如資料庫連線、檔案、鎖等,這些資源需要在用完之後釋放掉,否則會造成記憶體洩漏。

但是程式設計師都是人,是人就會犯錯。因此經常有程式設計師忘記關閉這些資源。Golang直接在語言層面提供defer關鍵字,在開啟資源語句的下一行,就可以直接用defer語句來註冊函式結束後執行關閉資源的操作。因為這樣一顆“小小”的語法糖,程式設計師忘寫關閉資源語句的情況就大大地減少了。

怎樣合理使用defer?

defer的使用其實非常簡單:

f,err := os.Open(filename)
if err != nil {
    panic(err)
}

if f != nil {
    defer f.Close()
}
複製程式碼

在開啟檔案的語句附近,用defer語句關閉檔案。這樣,在函式結束之前,會自動執行defer後面的語句來關閉檔案。

當然,defer會有小小地延遲,對時間要求特別特別特別高的程式,可以避免使用它,其他一般忽略它帶來的延遲。

defer進階

defer的底層原理是什麼?

我們先看一下官方對defer的解釋:

Each time a “defer” statement executes, the function value and parameters to the call are evaluated as usual and saved anew but the actual function is not invoked. Instead, deferred functions are invoked immediately before the surrounding function returns, in the reverse order they were deferred. If a deferred function value evaluates to nil, execution panics when the function is invoked, not when the “defer” statement is executed.

翻譯一下:每次defer語句執行的時候,會把函式“壓棧”,函式引數會被拷貝下來;當外層函式(非程式碼塊,如一個for迴圈)退出時,defer函式按照定義的逆序執行;如果defer執行的函式為nil, 那麼會在最終呼叫函式的產生panic.

defer語句並不會馬上執行,而是會進入一個棧,函式return前,會按先進後出的順序執行。也說是說最先被定義的defer語句最後執行。先進後出的原因是後面定義的函式可能會依賴前面的資源,自然要先執行;否則,如果前面先執行,那後面函式的依賴就沒有了。

在defer函式定義時,對外部變數的引用是有兩種方式的,分別是作為函式引數和作為閉包引用。作為函式引數,則在defer定義時就把值傳遞給defer,並被cache起來;作為閉包引用的話,則會在defer函式真正呼叫時根據整個上下文確定當前的值。

defer後面的語句在執行的時候,函式呼叫的引數會被儲存起來,也就是複製了一份。真正執行的時候,實際上用到的是這個複製的變數,因此如果此變數是一個“值”,那麼就和定義的時候是一致的。如果此變數是一個“引用”,那麼就可能和定義的時候不一致。

舉個例子:

func main() {
	var whatever [3]struct{}
	
	for i := range whatever {
		defer func() { 
			fmt.Println(i) 
		}()
	}
}
複製程式碼

執行結果:

2
2
2
複製程式碼

defer後面跟的是一個閉包(後面會講到),i是“引用”型別的變數,最後i的值為2, 因此最後列印了三個2.

有了上面的基礎,我們來檢驗一下成果:

type number int

func (n number) print()   { fmt.Println(n) }
func (n *number) pprint() { fmt.Println(*n) }

func main() {
	var n number

	defer n.print()
	defer n.pprint()
	defer func() { n.print() }()
	defer func() { n.pprint() }()

	n = 3
}
複製程式碼

執行結果是:

3
3
3
0
複製程式碼

第四個defer語句是閉包,引用外部函式的n, 最終結果是3; 第三個defer語句同第四個; 第二個defer語句,n是引用,最終求值是3. 第一個defer語句,對n直接求值,開始的時候n=0, 所以最後是0;

利用defer原理

有些情況下,我們會故意用到defer的先求值,再延遲呼叫的性質。想象這樣的場景:在一個函式裡,需要開啟兩個檔案進行合併操作,合併完後,在函式執行完後關閉開啟的檔案控制程式碼。

func mergeFile() error {
	f, _ := os.Open("file1.txt")
	if f != nil {
		defer func(f io.Closer) {
			if err := f.Close(); err != nil {
				fmt.Printf("defer close file1.txt err %v\n", err)
			}
		}(f)
	}

	// ……

	f, _ = os.Open("file2.txt")
	if f != nil {
		defer func(f io.Closer) {
			if err := f.Close(); err != nil {
				fmt.Printf("defer close file2.txt err %v\n", err)
			}
		}(f)
	}

	return nil
}
複製程式碼

上面的程式碼中就用到了defer的原理,defer函式定義的時候,引數就已經複製進去了,之後,真正執行close()函式的時候就剛好關閉的是正確的“檔案”了,妙哉!可以想像一下如果不這樣將f當成函式引數傳遞進去的話,最後兩個語句關閉的就是同一個檔案了,都是最後一個開啟的檔案。

不過在呼叫close()函式的時候,要注意一點:先判斷呼叫主體是否為空,否則會panic. 比如上面的程式碼片段裡,先判斷f不為空,才會呼叫Close()函式,這樣最安全。

defer命令的拆解

如果defer像上面介紹地那樣簡單(其實也不簡單啦),這個世界就完美了。事情總是沒這麼簡單,defer用得不好,是會跳進很多坑的。

理解這些坑的關鍵是這條語句:

return xxx
複製程式碼

上面這條語句經過編譯之後,變成了三條指令:

1. 返回值 = xxx
2. 呼叫defer函式
3. 空的return
複製程式碼

1,3步才是Return 語句真正的命令,第2步是defer定義的語句,這裡可能會操作返回值。

下面我們來看兩個例子,試著將return語句和defer語句拆解到正確的順序。

第一個例子:

func f() (r int) {
     t := 5
     defer func() {
       t = t + 5
     }()
     return t
}
複製程式碼

拆解後:

func f() (r int) {
     t := 5
     
     // 1. 賦值指令
     r = t
     
     // 2. defer被插入到賦值與返回之間執行,這個例子中返回值r沒被修改過
     func() {        
         t = t + 5
     }
     
     // 3. 空的return指令
     return
}
複製程式碼

這裡第二步沒有操作返回值r, 因此,main函式中呼叫f()得到5.

第二個例子:

func f() (r int) {
    defer func(r int) {
          r = r + 5
    }(r)
    return 1
}

複製程式碼

拆解後:

func f() (r int) {
     // 1. 賦值
     r = 1
     
     // 2. 這裡改的r是之前傳值傳進去的r,不會改變要返回的那個r值
     func(r int) { 
          r = r + 5
     }(r)
     
     // 3. 空的return
     return
}
複製程式碼

因此,main函式中呼叫f()得到1.

defer語句的引數

defer語句表示式的值在定義時就已經確定了。下面展示三個函式:

func f1() {
	var err error
	
	defer fmt.Println(err)

	err = errors.New("defer error")
	return
}

func f2() {
	var err error
	
	defer func() {
		fmt.Println(err)
	}()

	err = errors.New("defer error")
	return
}

func f3() {
	var err error
	
	defer func(err error) {
		fmt.Println(err)
	}(err)

	err = errors.New("defer error")
	return
}

func main() {
	f1()
	f2()
	f3()
}
複製程式碼

執行結果:

<nil>
defer error
<nil>
複製程式碼

第1,3個函式是因為作為函式引數,定義的時候就會求值,定義的時候err變數的值都是nil, 所以最後列印的時候都是nil. 第2個函式的引數其實也是會在定義的時候求值,只不過,第2個例子中是一個閉包,它引用的變數err在執行的時候最終變成defer error了。關於閉包在本文後面有介紹。

第3個函式的錯誤還比較容易犯,在生產環境中,很容易寫出這樣的錯誤程式碼。最後defer語句沒有起到作用。

閉包是什麼?

閉包是由函式及其相關引用環境組合而成的實體,即:

閉包=函式+引用環境
複製程式碼

一般的函式都有函式名,但是匿名函式就沒有。匿名函式不能獨立存在,但可以直接呼叫或者賦值於某個變數。匿名函式也被稱為閉包,一個閉包繼承了函式宣告時的作用域。在Golang中,所有的匿名函式都是閉包。

有個不太恰當的例子,可以把閉包看成是一個類,一個閉包函式呼叫就是例項化一個類。閉包在執行時可以有多個例項,它會將同一個作用域裡的變數和常量捕獲下來,無論閉包在什麼地方被呼叫(例項化)時,都可以使用這些變數和常量。而且,閉包捕獲的變數和常量是引用傳遞,不是值傳遞。

舉個簡單的例子:

func main() {
	var a = Accumulator()

	fmt.Printf("%d\n", a(1))
	fmt.Printf("%d\n", a(10))
	fmt.Printf("%d\n", a(100))

	fmt.Println("------------------------")
	var b = Accumulator()

	fmt.Printf("%d\n", b(1))
	fmt.Printf("%d\n", b(10))
	fmt.Printf("%d\n", b(100))


}

func Accumulator() func(int) int {
	var x int

	return func(delta int) int {
		fmt.Printf("(%+v, %+v) - ", &x, x)
		x += delta
		return x
	}
}
複製程式碼

執行結果:

(0xc420014070, 0) - 1
(0xc420014070, 1) - 11
(0xc420014070, 11) - 111
------------------------
(0xc4200140b8, 0) - 1
(0xc4200140b8, 1) - 11
(0xc4200140b8, 11) - 111
複製程式碼

閉包引用了x變數,a,b可看作2個不同的例項,例項之間互不影響。例項內部,x變數是同一個地址,因此具有“累加效應”。

defer配合recover

Golang被詬病比較多的就是它的error, 經常是各種error滿天飛。程式設計的時候總是會返回一個error, 留給呼叫者處理。如果是那種致命的錯誤,比如程式執行初始化的時候出問題,直接panic掉,省得上線執行後出更大的問題。

但是有些時候,我們需要從異常中恢復。比如伺服器程式遇到嚴重問題,產生了panic, 這時我們至少可以在程式崩潰前做一些“掃尾工作”,如關閉客戶端的連線,防止客戶端一直等待等等。

panic會停掉當前正在執行的程式,不只是當前協程。在這之前,它會有序地執行完當前協程defer列表裡的語句,其它協程裡掛的defer語句不作保證。因此,我們經常在defer裡掛一個recover語句,防止程式直接掛掉,這起到了try...catch的效果。

注意,recover()函式只在defer的上下文中才有效(且只有通過在defer中用匿名函式呼叫才有效),直接呼叫的話,只會返回nil.

func main() {
	defer fmt.Println("defer main")
	var user = os.Getenv("USER_")
	
	go func() {
		defer func() {
			fmt.Println("defer caller")
			if err := recover(); err != nil {
				fmt.Println("recover success. err: ", err)
			}
		}()

		func() {
			defer func() {
				fmt.Println("defer here")
			}()

			if user == "" {
				panic("should set user env.")
			}

			// 此處不會執行
			fmt.Println("after panic")
		}()
	}()

	time.Sleep(100)
	fmt.Println("end of main function")
}
複製程式碼

上面的panic最終會被recover捕獲到。這樣的處理方式在一個http server的主流程常常會被用到。一次偶然的請求可能會觸發某個bug, 這時用recover捕獲panic, 穩住主流程,不影響其他請求。

程式設計師通過監控獲知此次panic的發生,按時間點定位到日誌相應位置,找到發生panic的原因,三下五除二,修復上線。一看四周,大家都埋頭幹自己的事,簡直完美:偷偷修復了一個bug, 沒有發現!嘿嘿!

後記

defer非常好用,一般情況下不會有什麼問題。但是隻有深入理解了defer的原理才會避開它的溫柔陷阱。掌握了它的原理後,就會寫出易懂易維護的程式碼。

QR

參考資料

【defer那些事】xiaozhou.net/something-a… 【defer程式碼案例】tiancaiamao.gitbooks.io/go-internal… 【閉包】www.kancloud.cn/liupengjie/… 【閉包】blog.51cto.com/speakingbai… 【閉包】blog.csdn.net/zhangzhebju… 【延遲】liyangliang.me/posts/2014/… 【defer三條原則】leokongwq.github.io/2016/10/15/… 【defer程式碼例子】juejin.im/post/5b948b… 【defer panic】ieevee.com/tech/2017/1… 【defer panic】zhuanlan.zhihu.com/p/33743255

相關文章