淺析Go中函數(shù)的健壯性,panic異常處理和defer機(jī)制
一、函數(shù)健壯性的“三不要”原則
1.1 原則一:不要相信任何外部輸入的參數(shù)
函數(shù)的使用者可能是任何人,這些人在使用函數(shù)之前可能都沒(méi)有閱讀過(guò)任何手冊(cè)或文檔,他們會(huì)向函數(shù)傳入你意想不到的參數(shù)。因此,為了保證函數(shù)的健壯性,函數(shù)需要對(duì)所有輸入的參數(shù)進(jìn)行合法性的檢查。一旦發(fā)現(xiàn)問(wèn)題,立即終止函數(shù)的執(zhí)行,返回預(yù)設(shè)的錯(cuò)誤值。
1.2 原則二:不要忽略任何一個(gè)錯(cuò)誤
在我們的函數(shù)實(shí)現(xiàn)中,也會(huì)調(diào)用標(biāo)準(zhǔn)庫(kù)或第三方包提供的函數(shù)或方法。對(duì)于這些調(diào)用,我們不能假定它一定會(huì)成功,我們一定要顯式地檢查這些調(diào)用返回的錯(cuò)誤值。一旦發(fā)現(xiàn)錯(cuò)誤,要及時(shí)終止函數(shù)執(zhí)行,防止錯(cuò)誤繼續(xù)傳播。
1.3 原則三:不要假定異常不會(huì)發(fā)生
這里,我們先要確定一個(gè)認(rèn)知:異常不是錯(cuò)誤。錯(cuò)誤是可預(yù)期的,也是經(jīng)常會(huì)發(fā)生的,我們有對(duì)應(yīng)的公開錯(cuò)誤碼和錯(cuò)誤處理預(yù)案,但異常卻是少見的、意料之外的。通常意義上的異常,指的是硬件異常、操作系統(tǒng)異常、語(yǔ)言運(yùn)行時(shí)異常,還有更大可能是代碼中潛在 bug 導(dǎo)致的異常,比如代碼中出現(xiàn)了以 0 作為分母,或者是數(shù)組越界訪問(wèn)等情況。
雖然異常發(fā)生是“小眾事件”,但是我們不能假定異常不會(huì)發(fā)生。所以,函數(shù)設(shè)計(jì)時(shí),我們就需要根據(jù)函數(shù)的角色和使用場(chǎng)景,考慮是否要在函數(shù)內(nèi)設(shè)置異常捕捉和恢復(fù)的環(huán)節(jié)。
二、Go 語(yǔ)言中的異常:panic
2.1 panic 異常處理介紹
不同編程語(yǔ)言表示異常(Exception)這個(gè)概念的語(yǔ)法都不相同。在 Go 語(yǔ)言中,異常這個(gè)概念由 panic
表示。
panic 指的是 Go 程序在運(yùn)行時(shí)出現(xiàn)的一個(gè)異常情況。如果異常出現(xiàn)了,但沒(méi)有被捕獲并恢復(fù),Go 程序的執(zhí)行就會(huì)被終止,即便出現(xiàn)異常的位置不在主 Goroutine 中也會(huì)這樣。
在 Go 中,panic
主要有兩類來(lái)源,一類是來(lái)自 Go 運(yùn)行時(shí),另一類則是 Go 開發(fā)人員通過(guò) panic
函數(shù)主動(dòng)觸發(fā)的。無(wú)論是哪種,一旦 panic
被觸發(fā),后續(xù) Go 程序的執(zhí)行過(guò)程都是一樣的,這個(gè)過(guò)程被 Go 語(yǔ)言稱為 panicking
。
2.2 panicking 的過(guò)程
Go 官方文檔以手工調(diào)用 panic 函數(shù)觸發(fā) panic 為例,對(duì) panicking 這個(gè)過(guò)程進(jìn)行了詮釋:當(dāng)函數(shù) F 調(diào)用 panic 函數(shù)時(shí),函數(shù) F 的執(zhí)行將停止。不過(guò),函數(shù) F 中已進(jìn)行求值的 deferred 函數(shù)都會(huì)得到正常執(zhí)行,執(zhí)行完這些 deferred 函數(shù)后,函數(shù) F 才會(huì)把控制權(quán)返還給其調(diào)用者。
對(duì)于函數(shù) F 的調(diào)用者而言,函數(shù) F 之后的行為就如同調(diào)用者調(diào)用的函數(shù)是 panic 一樣,該 panicking 過(guò)程將繼續(xù)在棧上進(jìn)行下去,直到當(dāng)前 Goroutine 中的所有函數(shù)都返回為止,然后 Go 程序?qū)⒈罎⑼顺觥?/p>
package main import "fmt" func main() { f() fmt.Println("Returned normally from f.") } func f() { defer func() { if r := recover(); r != nil { fmt.Println("Recovered in f", r) } }() fmt.Println("Calling g.") g(0) fmt.Println("Returned normally from g.") } func g(i int) { if i > 3 { fmt.Println("Panicking!") panic(fmt.Sprintf("%v", i)) } defer fmt.Println("Defer in g", i) fmt.Println("Printing in g", i) g(i + 1) }
下面,我們用一個(gè)例子來(lái)更直觀地解釋一下 panicking
這個(gè)過(guò)程:
func foo() { println("call foo") bar() println("exit foo") } func bar() { println("call bar") panic("panic occurs in bar") zoo() println("exit bar") } func zoo() { println("call zoo") println("exit zoo") } func main() { println("call main") foo() println("exit main") }
上面這個(gè)例子中,從 Go 應(yīng)用入口開始,函數(shù)的調(diào)用次序依次為 main -> foo -> bar -> zoo。在 bar 函數(shù)中,我們調(diào)用 panic 函數(shù)手動(dòng)觸發(fā)了 panic。
我們執(zhí)行這個(gè)程序的輸出結(jié)果是這樣的:
call main
call foo
call bar
panic: panic occurs in bar
根據(jù)前面對(duì) panicking 過(guò)程的詮釋,理解一下這個(gè)例子。
這里,程序從入口函數(shù) main 開始依次調(diào)用了 foo、bar 函數(shù),在 bar 函數(shù)中,代碼在調(diào)用 zoo 函數(shù)之前調(diào)用了 panic 函數(shù)觸發(fā)了異常。那示例的 panicking 過(guò)程就從這開始了。bar 函數(shù)調(diào)用 panic 函數(shù)之后,它自身的執(zhí)行就此停止了,所以我們也沒(méi)有看到代碼繼續(xù)進(jìn)入 zoo 函數(shù)執(zhí)行。并且,bar 函數(shù)沒(méi)有捕捉這個(gè) panic,這樣這個(gè) panic 就會(huì)沿著函數(shù)調(diào)用棧向上走,來(lái)到了 bar 函數(shù)的調(diào)用者 foo 函數(shù)中。
從 foo 函數(shù)的視角來(lái)看,這就好比將它對(duì) bar 函數(shù)的調(diào)用,換成了對(duì) panic 函數(shù)的調(diào)用一樣。這樣一來(lái),foo 函數(shù)的執(zhí)行也被停止了。由于 foo 函數(shù)也沒(méi)有捕捉 panic,于是 panic 繼續(xù)沿著函數(shù)調(diào)用棧向上走,來(lái)到了 foo 函數(shù)的調(diào)用者 main 函數(shù)中。
同理,從 main 函數(shù)的視角來(lái)看,這就好比將它對(duì) foo 函數(shù)的調(diào)用,換成了對(duì) panic 函數(shù)的調(diào)用一樣。結(jié)果就是,main 函數(shù)的執(zhí)行也被終止了,于是整個(gè)程序異常退出,日志"exit main"也沒(méi)有得到輸出的機(jī)會(huì)。
2.3 recover 函數(shù)介紹
recover
是Go語(yǔ)言中的一個(gè)內(nèi)置函數(shù),用于在發(fā)生 panic
時(shí)捕獲并處理 panic
,以便程序能夠繼續(xù)執(zhí)行而不會(huì)完全崩潰。以下是有關(guān) recover
函數(shù)的介紹:
- 用途:
recover
用于恢復(fù)程序的控制權(quán),防止程序因panic
而崩潰。它通常與defer
一起使用,用于在發(fā)生異常情況時(shí)執(zhí)行一些清理操作、記錄錯(cuò)誤信息或者嘗試恢復(fù)程序狀態(tài)。 - 工作原理:當(dāng)程序進(jìn)入
panic
狀態(tài)時(shí),recover
可以用來(lái)停止panic
的傳播。它會(huì)返回導(dǎo)致panic
的值(通常是一個(gè)錯(cuò)誤信息),允許程序捕獲這個(gè)值并采取適當(dāng)?shù)拇胧H绻?nbsp;recover
在當(dāng)前函數(shù)內(nèi)沒(méi)有找到可捕獲的panic
,它會(huì)返回nil
。 - 與 panic 配合使用:通常,
recover
會(huì)與defer
一起使用。在defer
中使用recover
,可以確保在函數(shù)返回之前檢查panic
狀態(tài)并采取適當(dāng)?shù)拇胧?/li> - 局限性:
recover
只能用于捕獲最近一次的panic
,它不能用于捕獲之前的panic
。一旦recover
成功捕獲了一個(gè)panic
,它會(huì)重置panic
狀態(tài),因此無(wú)法繼續(xù)捕獲之前的panic
。
接著,我們繼續(xù)用上面這個(gè)例子分析,在觸發(fā) panic 的 bar 函數(shù)中,對(duì) panic 進(jìn)行捕捉并恢復(fù),我們直接來(lái)看恢復(fù)后,整個(gè)程序的執(zhí)行情況是什么樣的。這里,我們只列出了變更后的 bar 函數(shù)代碼,其他函數(shù)代碼并沒(méi)有改變,代碼如下:
package main import "fmt" func foo() { println("call foo") bar() println("exit foo") } // func bar() { // println("call bar") // panic("panic occurs in bar") // zoo() // println("exit bar") // } func bar() { defer func() { if e := recover(); e != nil { fmt.Println("recover the panic:", e) } }() println("call bar") panic("panic occurs in bar") zoo() println("exit bar") } func zoo() { println("call zoo") println("exit zoo") } func main() { println("call main") foo() println("exit main") }
在更新版的 bar 函數(shù)中,我們?cè)谝粋€(gè) defer 匿名函數(shù)中調(diào)用 recover 函數(shù)對(duì) panic 進(jìn)行了捕捉。recover 是 Go 內(nèi)置的專門用于恢復(fù) panic 的函數(shù),它必須被放在一個(gè) defer 函數(shù)中才能生效。如果 recover 捕捉到 panic,它就會(huì)返回以 panic 的具體內(nèi)容為錯(cuò)誤上下文信息的錯(cuò)誤值。如果沒(méi)有 panic 發(fā)生,那么 recover 將返回 nil。而且,如果 panic 被 recover 捕捉到,panic 引發(fā)的 panicking 過(guò)程就會(huì)停止。
我們執(zhí)行更新后的程序,得到如下結(jié)果:
call main
call foo
call bar
recover the panic: panic occurs in bar
exit foo
exit main
我們可以看到 main 函數(shù)終于得以“善終”。那這個(gè)過(guò)程中究竟發(fā)生了什么呢?
在更新后的代碼中,當(dāng) bar 函數(shù)調(diào)用 panic 函數(shù)觸發(fā)異常后,bar 函數(shù)的執(zhí)行就會(huì)被中斷。但這一次,在代碼執(zhí)行流回到 bar 函數(shù)調(diào)用者之前,bar 函數(shù)中的、在 panic 之前就已經(jīng)被設(shè)置成功的 derfer 函數(shù)就會(huì)被執(zhí)行。這個(gè)匿名函數(shù)會(huì)調(diào)用 recover 把剛剛觸發(fā)的 panic 恢復(fù),這樣,panic 還沒(méi)等沿著函數(shù)棧向上走,就被消除了。
所以,這個(gè)時(shí)候,從 foo 函數(shù)的視角來(lái)看,bar 函數(shù)與正常返回沒(méi)有什么差別。foo 函數(shù)依舊繼續(xù)向下執(zhí)行,直至 main 函數(shù)成功返回。這樣,這個(gè)程序的 panic“危機(jī)”就解除了。
面對(duì)有如此行為特點(diǎn)的 panic,我們應(yīng)該如何應(yīng)對(duì)呢?是不是在所有 Go 函數(shù)或方法中,我們都要用 defer 函數(shù)來(lái)捕捉和恢復(fù) panic 呢?
三、如何應(yīng)對(duì) panic
其實(shí)大可不必。一來(lái),這樣做會(huì)徒增開發(fā)人員函數(shù)實(shí)現(xiàn)時(shí)的心智負(fù)擔(dān)。二來(lái),很多函數(shù)非常簡(jiǎn)單,根本不會(huì)出現(xiàn) panic
情況,我們?cè)黾?nbsp;panic
捕獲和恢復(fù),反倒會(huì)增加函數(shù)的復(fù)雜性。同時(shí),defer
函數(shù)也不是“免費(fèi)”的,也有帶來(lái)性能開銷。
日常情況下,我們應(yīng)該采取以下3點(diǎn)經(jīng)驗(yàn)。
3.1 第一點(diǎn):評(píng)估程序?qū)?panic 的忍受度
首先,我們應(yīng)該知道一個(gè)事實(shí):不同應(yīng)用對(duì)異常引起的程序崩潰退出的忍受度是不一樣的。比如,一個(gè)單次運(yùn)行于控制臺(tái)窗口中的命令行交互類程序(CLI),和一個(gè)常駐內(nèi)存的后端 HTTP 服務(wù)器程序,對(duì)異常崩潰的忍受度就是不同的。
前者即便因異常崩潰,對(duì)用戶來(lái)說(shuō)也僅僅是再重新運(yùn)行一次而已。但后者一旦崩潰,就很可能導(dǎo)致整個(gè)網(wǎng)站停止服務(wù)。所以,針對(duì)各種應(yīng)用對(duì) panic 忍受度的差異,我們采取的應(yīng)對(duì) panic 的策略也應(yīng)該有不同。像后端 HTTP 服務(wù)器程序這樣的任務(wù)關(guān)鍵系統(tǒng),我們就需要在特定位置捕捉并恢復(fù) panic,以保證服務(wù)器整體的健壯度。在這方面,Go 標(biāo)準(zhǔn)庫(kù)中的 http server 就是一個(gè)典型的代表。
Go 標(biāo)準(zhǔn)庫(kù)提供的 http server 采用的是,每個(gè)客戶端連接都使用一個(gè)單獨(dú)的 Goroutine 進(jìn)行處理的并發(fā)處理模型。也就是說(shuō),客戶端一旦與 http server 連接成功,http server 就會(huì)為這個(gè)連接新創(chuàng)建一個(gè) Goroutine,并在這 Goroutine 中執(zhí)行對(duì)應(yīng)連接(conn)的 serve 方法,來(lái)處理這條連接上的客戶端請(qǐng)求。
前面提到了 panic 的“危害”時(shí),我們說(shuō)過(guò),無(wú)論在哪個(gè) Goroutine 中發(fā)生未被恢復(fù)的 panic,整個(gè)程序都將崩潰退出。所以,為了保證處理某一個(gè)客戶端連接的 Goroutine 出現(xiàn) panic 時(shí),不影響到 http server 主 Goroutine 的運(yùn)行,Go 標(biāo)準(zhǔn)庫(kù)在 serve 方法中加入了對(duì) panic 的捕捉與恢復(fù),下面是 serve 方法的部分代碼片段:
// $GOROOT/src/net/http/server.go // Serve a new connection. func (c *conn) serve(ctx context.Context) { c.remoteAddr = c.rwc.RemoteAddr().String() ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr()) defer func() { if err := recover(); err != nil && err != ErrAbortHandler { const size = 64 << 10 buf := make([]byte, size) buf = buf[:runtime.Stack(buf, false)] c.server.logf("http: panic serving %v: %v\n%s", c.remoteAddr, err, buf) } if !c.hijacked() { c.close() c.setState(c.rwc, StateClosed, runHooks) } }() ... ... }
可以看到,serve 方法在一開始處就設(shè)置了 defer 函數(shù),并在該函數(shù)中捕捉并恢復(fù)了可能出現(xiàn)的 panic。這樣,即便處理某個(gè)客戶端連接的 Goroutine 出現(xiàn) panic,處理其他連接 Goroutine 以及 http server 自身都不會(huì)受到影響。
這種局部不要影響整體的異常處理策略,在很多并發(fā)程序中都有應(yīng)用。并且,捕捉和恢復(fù) panic 的位置通常都在子 Goroutine 的起始處,這樣設(shè)置可以捕捉到后面代碼中可能出現(xiàn)的所有 panic,就像 serve 方法中那樣。
3.2 第二點(diǎn):提示潛在 bug
有了對(duì) panic 忍受度的評(píng)估,panic 也沒(méi)有那么“恐怖”,而且,我們甚至可以借助 panic 來(lái)幫助我們快速找到潛在 bug。
Go 語(yǔ)言標(biāo)準(zhǔn)庫(kù)中并沒(méi)有提供斷言之類的輔助函數(shù),但我們可以使用 panic,部分模擬斷言對(duì)潛在 bug 的提示功能。比如,下面就是標(biāo)準(zhǔn)庫(kù) encoding/json包使用 panic 指示潛在 bug 的一個(gè)例子:
// $GOROOT/src/encoding/json/decode.go ... ... //當(dāng)一些本不該發(fā)生的事情導(dǎo)致我們結(jié)束處理時(shí),phasePanicMsg將被用作panic消息 //它可以指示JSON解碼器中的bug,或者 //在解碼器執(zhí)行時(shí)還有其他代碼正在修改數(shù)據(jù)切片。 const phasePanicMsg = "JSON decoder out of sync - data changing underfoot?" func (d *decodeState) init(data []byte) *decodeState { d.data = data d.off = 0 d.savedError = nil if d.errorContext != nil { d.errorContext.Struct = nil // Reuse the allocated space for the FieldStack slice. d.errorContext.FieldStack = d.errorContext.FieldStack[:0] } return d } func (d *decodeState) valueQuoted() interface{} { switch d.opcode { default: panic(phasePanicMsg) case scanBeginArray, scanBeginObject: d.skip() d.scanNext() case scanBeginLiteral: v := d.literalInterface() switch v.(type) { case nil, string: return v } } return unquotedValue{} }
我們看到,在 valueQuoted
這個(gè)方法中,如果程序執(zhí)行流進(jìn)入了 default
分支,那這個(gè)方法就會(huì)引發(fā) panic
,這個(gè) panic 會(huì)提示開發(fā)人員:這里很可能是一個(gè) bug。
同樣,在 json 包的 encode.go 中也有使用 panic 做潛在 bug 提示的例子:
// $GOROOT/src/encoding/json/encode.go func (w *reflectWithString) resolve() error { ... ... switch w.k.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: w.ks = strconv.FormatInt(w.k.Int(), 10) return nil case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: w.ks = strconv.FormatUint(w.k.Uint(), 10) return nil } panic("unexpected map key type") }
這段代碼中,resolve
方法的最后一行代碼就相當(dāng)于一個(gè)“代碼邏輯不會(huì)走到這里”的斷言。一旦觸發(fā)“斷言”,這很可能就是一個(gè)潛在 bug。
我們也看到,去掉這行代碼并不會(huì)對(duì) resolve 方法的邏輯造成任何影響,但真正出現(xiàn)問(wèn)題時(shí),開發(fā)人員就缺少了“斷言”潛在 bug 提醒的輔助支持了。在 Go 標(biāo)準(zhǔn)庫(kù)中,大多數(shù) panic 的使用都是充當(dāng)類似斷言的作用的。
3.3 第三點(diǎn):不要混淆異常與錯(cuò)誤
在日常編碼中,一些 Go 語(yǔ)言初學(xué)者,尤其是一些有過(guò)Python,Java等語(yǔ)言編程經(jīng)驗(yàn)的程序員,會(huì)因?yàn)榱?xí)慣了 Python 那種基于try
-except
的錯(cuò)誤處理思維,而將 Go panic 當(dāng)成Python 的“checked exception”去用,這顯然是混淆了 Go 中的異常與錯(cuò)誤,這是 Go 錯(cuò)誤處理的一種反模式。
查看Python
標(biāo)準(zhǔn)類庫(kù),我們可以看到一些 Java 已預(yù)定義好的 checked exception 類,比較常見的有ValueError
、TypeError
等等??吹竭@里,這些 checked exception 都是預(yù)定義好的、代表特定場(chǎng)景下的錯(cuò)誤狀態(tài)。
那 Python 的 checked exception 和 Go 中的 panic 有啥差別呢?
Python 的 checked exception 用于一些可預(yù)見的、常會(huì)發(fā)生的錯(cuò)誤場(chǎng)景,比如,針對(duì) checked exception 的所謂“異常處理”,就是針對(duì)這些場(chǎng)景的“錯(cuò)誤處理預(yù)案”。也可以說(shuō)對(duì) checked exception 的使用、捕獲、自定義等行為都是“有意而為之”的。
如果它非要和 Go 中的某種語(yǔ)法對(duì)應(yīng)來(lái)看,它對(duì)應(yīng)的也是 Go 的錯(cuò)誤處理,也就是基于 error 值比較模型的錯(cuò)誤處理。所以,Python 中對(duì) checked exception 處理的本質(zhì)是錯(cuò)誤處理,雖然它的名字用了帶有“異常”的字樣。
而 Go 中的 panic 呢,更接近于 Python 的 RuntimeException,而不是 checked exception 。我們前面提到過(guò) Python 的 checked exception 是必須要被上層代碼處理的,也就是要么捕獲處理,要么重新拋給更上層。但是在 Go 中,我們通常會(huì)導(dǎo)入大量第三方包,而對(duì)于這些第三方包 API 中是否會(huì)引發(fā) panic ,我們是不知道的。
因此上層代碼,也就是 API 調(diào)用者根本不會(huì)去逐一了解 API 是否會(huì)引發(fā)panic
,也沒(méi)有義務(wù)去處理引發(fā)的 panic
。一旦你在編寫的 API 中,像 checked exception
那樣使用 panic 作為正常錯(cuò)誤處理的手段,把引發(fā)的 panic
當(dāng)作錯(cuò)誤,那么你就會(huì)給你的 API 使用者帶去大麻煩!因此,在 Go 中,作為 API 函數(shù)的作者,你一定不要將 panic 當(dāng)作錯(cuò)誤返回給 API 調(diào)用者。
四、defer 函數(shù)
在Go語(yǔ)言中,defer
是一種用于延遲執(zhí)行函數(shù)或方法調(diào)用的機(jī)制。它通常用于執(zhí)行清理操作、資源釋放、日志記錄等,以確保在函數(shù)返回之前進(jìn)行這些操作。下面是有關(guān) defer
函數(shù)的介紹和如何使用它來(lái)簡(jiǎn)化函數(shù)實(shí)現(xiàn)的內(nèi)容:
4.1 defer 函數(shù)介紹
- 延遲執(zhí)行:
defer
允許將一個(gè)函數(shù)或方法調(diào)用推遲到當(dāng)前函數(shù)返回之前執(zhí)行,無(wú)論是正常返回還是由于panic
引起的異常返回。 - 執(zhí)行順序:多個(gè)
defer
語(yǔ)句按照后進(jìn)先出(LIFO)的順序執(zhí)行,即最后一個(gè)注冊(cè)的defer
最先執(zhí)行,倒數(shù)第二個(gè)注冊(cè)的defer
在其后執(zhí)行,以此類推。 - 常見用途:
defer
常用于資源管理,例如文件關(guān)閉、互斥鎖的釋放、數(shù)據(jù)庫(kù)連接的關(guān)閉等,也用于執(zhí)行一些必要的清理工作或日志記錄。 - 不僅限于函數(shù)調(diào)用:
defer
不僅可以用于函數(shù)調(diào)用,還可以用于方法調(diào)用,匿名函數(shù)的執(zhí)行等。
4.2 使用 defer 簡(jiǎn)化函數(shù)實(shí)現(xiàn)
對(duì)函數(shù)設(shè)計(jì)來(lái)說(shuō),如何實(shí)現(xiàn)簡(jiǎn)潔的目標(biāo)是一個(gè)大話題。你可以從通用的設(shè)計(jì)原則去談,比如函數(shù)要遵守單一職責(zé),職責(zé)單一的函數(shù)肯定要比擔(dān)負(fù)多種職責(zé)的函數(shù)更簡(jiǎn)單。你也可以從函數(shù)實(shí)現(xiàn)的規(guī)模去談,比如函數(shù)體的規(guī)模要小,盡量控制在 80 行代碼之內(nèi)等。
Go 中提供了defer
可以幫助我們簡(jiǎn)化 Go 函數(shù)的設(shè)計(jì)和實(shí)現(xiàn)。我們用一個(gè)具體的例子來(lái)理解一下。日常開發(fā)中,我們經(jīng)常會(huì)編寫一些類似下面示例中的偽代碼:
func doSomething() error { var mu sync.Mutex mu.Lock() r1, err := OpenResource1() if err != nil { mu.Unlock() return err } r2, err := OpenResource2() if err != nil { r1.Close() mu.Unlock() return err } r3, err := OpenResource3() if err != nil { r2.Close() r1.Close() mu.Unlock() return err } // 使用r1,r2, r3 err = doWithResources() if err != nil { r3.Close() r2.Close() r1.Close() mu.Unlock() return err } r3.Close() r2.Close() r1.Close() mu.Unlock() return nil }
我們看到,這類代碼的特點(diǎn)就是在函數(shù)中會(huì)申請(qǐng)一些資源,并在函數(shù)退出前釋放或關(guān)閉這些資源,比如這里的互斥鎖 mu 以及資源 r1~r3
就是這樣。
函數(shù)的實(shí)現(xiàn)需要確保,無(wú)論函數(shù)的執(zhí)行流是按預(yù)期順利進(jìn)行,還是出現(xiàn)錯(cuò)誤,這些資源在函數(shù)退出時(shí)都要被及時(shí)、正確地釋放。為此,我們需要尤為關(guān)注函數(shù)中的錯(cuò)誤處理,在錯(cuò)誤處理時(shí)不能遺漏對(duì)資源的釋放。
但這樣的要求,就導(dǎo)致我們?cè)谶M(jìn)行資源釋放,尤其是有多個(gè)資源需要釋放的時(shí)候,比如上面示例那樣,會(huì)大大增加開發(fā)人員的心智負(fù)擔(dān)。同時(shí)當(dāng)待釋放的資源個(gè)數(shù)較多時(shí),整個(gè)代碼邏輯就會(huì)變得十分復(fù)雜,程序可讀性、健壯性也會(huì)隨之下降。但即便如此,如果函數(shù)實(shí)現(xiàn)中的某段代碼邏輯拋出 panic,傳統(tǒng)的錯(cuò)誤處理機(jī)制依然沒(méi)有辦法捕獲它并嘗試從 panic 恢復(fù)。
Go 語(yǔ)言引入 defer 的初衷,就是解決這些問(wèn)題。那么,defer 具體是怎么解決這些問(wèn)題的呢?或者說(shuō),defer 具體的運(yùn)作機(jī)制是怎樣的呢?
defer
是 Go 語(yǔ)言提供的一種延遲調(diào)用機(jī)制,defer 的運(yùn)作離不開函數(shù)。怎么理解呢?這句話至少有以下兩點(diǎn)含義:
- 在 Go 中,只有在函數(shù)(和方法)內(nèi)部才能使用 defer;
- defer 關(guān)鍵字后面只能接函數(shù)(或方法),這些函數(shù)被稱為 deferred 函數(shù)。defer 將它們注冊(cè)到其所在 Goroutine 中,用于存放 deferred 函數(shù)的棧數(shù)據(jù)結(jié)構(gòu)中,這些 deferred 函數(shù)將在執(zhí)行 defer 的函數(shù)退出前,按后進(jìn)先出(LIFO)的順序被程序調(diào)度執(zhí)行(如下圖所示)。
而且,無(wú)論是執(zhí)行到函數(shù)體尾部返回,還是在某個(gè)錯(cuò)誤處理分支顯式 return,又或是出現(xiàn) panic,已經(jīng)存儲(chǔ)到 deferred 函數(shù)棧中的函數(shù),都會(huì)被調(diào)度執(zhí)行。所以說(shuō),deferred 函數(shù)是一個(gè)可以在任何情況下為函數(shù)進(jìn)行收尾工作的好“伙伴”。
我們回到剛才的那個(gè)例子,如果我們把收尾工作挪到 deferred 函數(shù)中,那么代碼將變成如下這個(gè)樣子:
func doSomething() error { var mu sync.Mutex mu.Lock() defer mu.Unlock() r1, err := OpenResource1() if err != nil { return err } defer r1.Close() r2, err := OpenResource2() if err != nil { return err } defer r2.Close() r3, err := OpenResource3() if err != nil { return err } defer r3.Close() // 使用r1,r2, r3 return doWithResources() }
我們看到,使用 defer 后對(duì)函數(shù)實(shí)現(xiàn)邏輯的簡(jiǎn)化是顯而易見的。而且,這里資源釋放函數(shù)的 defer 注冊(cè)動(dòng)作,緊鄰著資源申請(qǐng)成功的動(dòng)作,這樣成對(duì)出現(xiàn)的慣例就極大降低了遺漏資源釋放的可能性,我們開發(fā)人員也不用再小心翼翼地在每個(gè)錯(cuò)誤處理分支中檢查是否遺漏了某個(gè)資源的釋放動(dòng)作。同時(shí),代碼的簡(jiǎn)化也意味代碼可讀性的提高,以及代碼健壯度的增強(qiáng)。
五、defer 使用的幾個(gè)注意事項(xiàng)
大多數(shù) Gopher 都喜歡 defer,因?yàn)樗粌H可以用來(lái)捕捉和恢復(fù) panic,還能讓函數(shù)變得更簡(jiǎn)潔和健壯。但“工欲善其事,必先利其器“,一旦你要用 defer
,有幾個(gè)關(guān)于 defer 使用的注意事項(xiàng)是你一定要提前了解清楚的,可以避免掉進(jìn)一些不必要的“坑”。
5.1 第一點(diǎn):明確哪些函數(shù)可以作為 deferred 函數(shù)
這里,你要清楚,對(duì)于自定義的函數(shù)或方法,defer
可以給與無(wú)條件的支持,但是對(duì)于有返回值的自定義函數(shù)或方法,返回值會(huì)在 deferred 函數(shù)被調(diào)度執(zhí)行的時(shí)候被自動(dòng)丟棄。
而且,Go 語(yǔ)言中除了自定義函數(shù) / 方法,還有 Go 語(yǔ)言內(nèi)置的 / 預(yù)定義的函數(shù),這里我給出了 Go 語(yǔ)言內(nèi)置函數(shù)的完全列表:
Functions:
append cap close complex copy delete imag len
make new panic print println real recover
那么,Go 語(yǔ)言中的內(nèi)置函數(shù)是否都能作為 deferred 函數(shù)呢?我們看下面的示例:
// defer1.go func bar() (int, int) { return 1, 2 } func foo() { var c chan int var sl []int var m = make(map[string]int, 10) m["item1"] = 1 m["item2"] = 2 var a = complex(1.0, -1.4) var sl1 []int defer bar() defer append(sl, 11) defer cap(sl) defer close(c) defer complex(2, -2) defer copy(sl1, sl) defer delete(m, "item2") defer imag(a) defer len(sl) defer make([]int, 10) defer new(*int) defer panic(1) defer print("hello, defer\n") defer println("hello, defer") defer real(a) defer recover() } func main() { foo() }
運(yùn)行這個(gè)示例代碼,我們可以得到:
$go run defer1.go
# command-line-arguments
./defer1.go:17:2: defer discards result of append(sl, 11)
./defer1.go:18:2: defer discards result of cap(sl)
./defer1.go:20:2: defer discards result of complex(2, -2)
./defer1.go:23:2: defer discards result of imag(a)
./defer1.go:24:2: defer discards result of len(sl)
./defer1.go:25:2: defer discards result of make([]int, 10)
./defer1.go:26:2: defer discards result of new(*int)
./defer1.go:30:2: defer discards result of real(a)
我們看到,Go 編譯器居然給出一組錯(cuò)誤提示!
從這組錯(cuò)誤提示中我們可以看到,append
、cap
、len
、make
、new
、imag
等內(nèi)置函數(shù)都是不能直接作為 deferred
函數(shù)的,而 close
、copy
、delete、print
、recover
等內(nèi)置函數(shù)則可以直接被 defer
設(shè)置為 deferred
函數(shù)。
不過(guò),對(duì)于那些不能直接作為 deferred 函數(shù)的內(nèi)置函數(shù),我們可以使用一個(gè)包裹它的匿名函數(shù)來(lái)間接滿足要求,以 append 為例是這樣的:
defer func() { _ = append(sl, 11) }()
5.2 第二點(diǎn):注意 defer 關(guān)鍵字后面表達(dá)式的求值時(shí)機(jī)
這里,一定要牢記一點(diǎn):defer
關(guān)鍵字后面的表達(dá)式,是在將 deferred
函數(shù)注冊(cè)到 deferred
函數(shù)棧的時(shí)候進(jìn)行求值的。
我們同樣用一個(gè)典型的例子來(lái)說(shuō)明一下 defer
后表達(dá)式的求值時(shí)機(jī):
func foo1() { for i := 0; i <= 3; i++ { defer fmt.Println(i) } } func foo2() { for i := 0; i <= 3; i++ { defer func(n int) { fmt.Println(n) }(i) } } func foo3() { for i := 0; i <= 3; i++ { defer func() { fmt.Println(i) }() } } func main() { fmt.Println("foo1 result:") foo1() fmt.Println("\nfoo2 result:") foo2() fmt.Println("\nfoo3 result:") foo3() }
這里,我們一個(gè)個(gè)分析 foo1、foo2 和 foo3 中 defer 后的表達(dá)式的求值時(shí)機(jī)。
首先是 foo1。foo1 中 defer 后面直接用的是 fmt.Println 函數(shù),每當(dāng) defer 將 fmt.Println 注冊(cè)到 deferred 函數(shù)棧的時(shí)候,都會(huì)對(duì) Println 后面的參數(shù)進(jìn)行求值。根據(jù)上述代碼邏輯,依次壓入 deferred 函數(shù)棧的函數(shù)是:
fmt.Println(0) fmt.Println(1) fmt.Println(2) fmt.Println(3)
因此,當(dāng) foo1 返回后,deferred 函數(shù)被調(diào)度執(zhí)行時(shí),上述壓入棧的 deferred 函數(shù)將以 LIFO 次序出棧執(zhí)行,這時(shí)的輸出的結(jié)果為:
3
2
1
0
然后我們?cè)倏?foo2。foo2 中 defer 后面接的是一個(gè)帶有一個(gè)參數(shù)的匿名函數(shù)。每當(dāng) defer
將匿名函數(shù)注冊(cè)到 deferred
函數(shù)棧的時(shí)候,都會(huì)對(duì)該匿名函數(shù)的參數(shù)進(jìn)行求值。根據(jù)上述代碼邏輯,依次壓入 deferred
函數(shù)棧的函數(shù)是:
func(0) func(1) func(2) func(3)
因此,當(dāng) foo2 返回后,deferred 函數(shù)被調(diào)度執(zhí)行時(shí),上述壓入棧的 deferred 函數(shù)將以 LIFO 次序出棧執(zhí)行,因此輸出的結(jié)果為:
3
2
1
0
最后我們來(lái)看 foo3。foo3 中 defer 后面接的是一個(gè)不帶參數(shù)的匿名函數(shù)。根據(jù)上述代碼邏輯,依次壓入 deferred 函數(shù)棧的函數(shù)是:
func() func() func() func()
所以,當(dāng) foo3 返回后,deferred 函數(shù)被調(diào)度執(zhí)行時(shí),上述壓入棧的 deferred 函數(shù)將以 LIFO 次序出棧執(zhí)行。匿名函數(shù)會(huì)以閉包的方式訪問(wèn)外圍函數(shù)的變量 i,并通過(guò) Println 輸出 i 的值,此時(shí) i 的值為 4,因此 foo3 的輸出結(jié)果為:
4
4
4
4
通過(guò)這些例子,我們可以看到,無(wú)論以何種形式將函數(shù)注冊(cè)到 defer
中,deferred
函數(shù)的參數(shù)值都是在注冊(cè)的時(shí)候進(jìn)行求值的。
5.3 第三點(diǎn):知曉 defer 帶來(lái)的性能損耗
通過(guò)前面的分析,我們可以看到,defer 讓我們進(jìn)行資源釋放(如文件描述符、鎖)的過(guò)程變得優(yōu)雅很多,也不易出錯(cuò)。但在性能敏感的應(yīng)用中,defer 帶來(lái)的性能負(fù)擔(dān)也是我們必須要知曉和權(quán)衡的問(wèn)題。
這里,我們用一個(gè)性能基準(zhǔn)測(cè)試(Benchmark),直觀地看看 defer 究竟會(huì)帶來(lái)多少性能損耗?;?Go 工具鏈,我們可以很方便地為 Go 源碼寫一個(gè)性能基準(zhǔn)測(cè)試,只需將代碼放在以“_test.go”為后綴的源文件中,然后利用 testing 包提供的“框架”就可以了,我們看下面代碼:
// defer_test.go package main import "testing" func sum(max int) int { total := 0 for i := 0; i < max; i++ { total += i } return total } func fooWithDefer() { defer func() { sum(10) }() } func fooWithoutDefer() { sum(10) } func BenchmarkFooWithDefer(b *testing.B) { for i := 0; i < b.N; i++ { fooWithDefer() } } func BenchmarkFooWithoutDefer(b *testing.B) { for i := 0; i < b.N; i++ { fooWithoutDefer() } }
這個(gè)基準(zhǔn)測(cè)試包含了兩個(gè)測(cè)試用例,分別是 BenchmarkFooWithDefer 和 BenchmarkFooWithoutDefer。前者測(cè)量的是帶有 defer 的函數(shù)執(zhí)行的性能,后者測(cè)量的是不帶有 defer 的函數(shù)的執(zhí)行的性能。
在 Go 1.13 前的版本中,defer 帶來(lái)的開銷還是很大的。我們先用 Go 1.12.7 版本來(lái)運(yùn)行一下上述基準(zhǔn)測(cè)試,我們會(huì)得到如下結(jié)果:
$go test -bench . defer_test.go
goos: darwin
goarch: amd64
BenchmarkFooWithDefer-8 30000000 42.6 ns/op
BenchmarkFooWithoutDefer-8 300000000 5.44 ns/op
PASS
ok command-line-arguments 3.511s
從這個(gè)基準(zhǔn)測(cè)試結(jié)果中,我們可以清晰地看到:使用 defer
的函數(shù)的執(zhí)行時(shí)間是沒(méi)有使用 defer
函數(shù)的 8 倍左右。
如果我們要用好 defer
,前提就是要了解 defer 的運(yùn)作機(jī)制,這里你要把握住兩點(diǎn):
- 函數(shù)返回前,deferred 函數(shù)是按照后入先出(LIFO)的順序執(zhí)行的;
- defer 關(guān)鍵字是在注冊(cè)函數(shù)時(shí)對(duì)函數(shù)的參數(shù)進(jìn)行求值的。
最后,在最新 Go 版本 Go1.17 中,使用 defer 帶來(lái)的開銷幾乎可以忽略不計(jì)了,你可以放心使用。
以上就是淺析Go中函數(shù)的健壯性,panic異常處理和defer機(jī)制的詳細(xì)內(nèi)容,更多關(guān)于Go函數(shù)的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Go?Excelize?API源碼閱讀SetSheetViewOptions示例解析
這篇文章主要為大家介紹了Go-Excelize?API源碼閱讀SetSheetViewOptions示例解析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-08-08詳解golang 定時(shí)任務(wù)time.Sleep和time.Tick實(shí)現(xiàn)結(jié)果比較
本文主要介紹了golang 定時(shí)任務(wù)time.Sleep和time.Tick實(shí)現(xiàn)結(jié)果比較,文中通過(guò)示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-02-02golang連接池檢查連接失敗時(shí)如何重試(示例代碼)
在Go中,可以通過(guò)使用database/sql包的DB類型的Ping方法來(lái)檢查數(shù)據(jù)庫(kù)連接的可用性,本文通過(guò)示例代碼,演示了如何在連接檢查失敗時(shí)進(jìn)行重試,感興趣的朋友一起看看吧2023-10-10golang?recover函數(shù)使用中的一些坑解析
這篇文章主要為大家介紹了golang?recover函數(shù)使用中的一些坑解析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-02-02IdeaGo啟動(dòng)報(bào)錯(cuò)Failed to create JVM的問(wèn)題解析
這篇文章主要介紹了IdeaGo啟動(dòng)報(bào)錯(cuò)Failed to create JVM的問(wèn)題,本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-11-11