Golang 高效實踐之defer、panic、recover實踐

我是碼客發表於2019-07-22

 前言

我們知道Golang處理異常是用error返回的方式,然後呼叫方根據error的值走不同的處理邏輯。但是,如果程式觸發其他的嚴重異常,比如說陣列越界,程式就要直接崩潰。Golang有沒有一種異常捕獲和恢復機制呢?這個就是本文要講的panic和recover。其中recover要配合defer使用才能發揮出效果。

Defer

Defer語句將一個函式放入一個列表(用棧表示其實更準確)中,該列表的函式在環繞defer的函式返回時會被執行。defer通常用於簡化函式的各種各樣清理動作,例如關閉檔案,解鎖等等的釋放資源的動作。例如下面的這個函式開啟兩個檔案,從一個檔案拷貝內容到另外的一個檔案:

func CopyFile(dstName, srcName string) (written int64, err error) {
    src, err := os.Open(srcName)
    if err != nil {
        return
    }

    dst, err := os.Create(dstName)
    if err != nil {
        return
    }

    written, err = io.Copy(dst, src)
    dst.Close()
    src.Close()
    return
}

這段程式碼可以工作,但是有一個bug。如果呼叫os.Create失敗,函式將會直接返回,並沒有關閉srcName檔案。修復的方法很簡單,可以把src.Close的呼叫放在第二個return語句前面。但是當我們程式的分支比較多的時候,也就是說當該函式還有幾個其他的return語句時,就需要在每個分支return前都要加上close動作。這樣使得資源的清理非常繁瑣而且容易遺漏。所以Golang引入了defer語句:

func CopyFile(dstName, srcName string) (written int64, err error) {
    src, err := os.Open(srcName)
    if err != nil {
        return
    }
    defer src.Close()

    dst, err := os.Create(dstName)
    if err != nil {
        return
    }
    defer dst.Close()

    return io.Copy(dst, src)
}

在每個資源申請成功的後面都加上defer自動清理,不管該函式都多少個return,資源都會被正確的釋放,例如上述例子的檔案一定會被關閉。

關閉defer語句,有三條簡單的規則:

1.defer的函式在壓棧的時候也會儲存引數的值,並非在執行時取值。

func a() {
    i := 0
    defer fmt.Println(i)
    i++
    return
}

例如該示例中,變數i會在defer時就被儲存起來,所以defer函式執行時i的值是0.即便後面i的值變為了1,也不會影響之前的拷貝。

2.defer函式呼叫的順序是後進先出。

func b() {
    for i := 0; i < 4; i++ {
        defer fmt.Print(i)
    }
}

函式輸出3210

3.defer函式可以讀取和重新賦值函式的命名返回引數。

func c() (i int) {
    defer func() { i++ }()
    return 1
}

這個例子中,defer函式中在函式返回時對命名返回值i進行了加1操作,因此函式返回值是2.可能你會有疑問,規則1不是說會在defer時儲存i的值嗎?儲存的i是0,那加1操作之後也是1啊。這裡就是閉包的魅力,i的值會被立馬儲存,但是儲存的是i的引用,也可以理解為指標。當實際執行加1操作時,i的值其實被return置為了1,defer執行了加1操作i的值也就變成了2.

Panic

Panic是內建的停止控制流的函式。相當於其他程式語言的拋異常操作。當函式F呼叫了panic,F的執行會被停止,在F中panic前面定義的defer操作都會被執行,然後F函式返回。對於呼叫者來說,呼叫F的行為就像呼叫panic(如果F函式內部沒有把panic recover掉)。如果都沒有捕獲該panic,相當於一層層panic,程式將會crash。panic可以直接呼叫,也可以是程式執行時錯誤導致,例如陣列越界。

Recover

Recover是一個從panic恢復的內建函式。Recover只有在defer的函式裡面才能發揮真正的作用。如果是正常的情況(沒有發生panic),呼叫recover將會返回nil並且沒有任何影響。如果當前的goroutine panic了,recover的呼叫將會捕獲到panic的值,並且恢復正常執行。

例如下面這個例子:

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接受引數i,如果i大於3時觸發panic,否則對i進行加1操作。函式f的defer函式裡面呼叫了recover並且列印recover的值(非nil的話)。

程式將會輸出:

Calling g.
Printing in g 0
Printing in g 1
Printing in g 2
Printing in g 3
Panicking!
Defer in g 3
Defer in g 2
Defer in g 1
Defer in g 0
Recovered in f 4
Returned normally from f.

Panic和recover可以接受任何型別的值,因為定義為interface{}:

func panic(v interface{})

func recover() interface{}

所以工作模式相當於:

panic(value)->recover()->value

傳遞給panic的value最終由recover捕獲。

 

另外defer可以配合鎖的使用來確保鎖的釋放,例如:

mu.Lock()

Defer mu.Unlock()

需要注意的是這樣會延長鎖的釋放時間(需要等到函式return)。

 

容易踩坑的一些例子

通過上面的說明,我們已經對defer,panic和recover有了比較清晰的認識,下面通過一些實戰中容易踩坑的例子來加深下印象。

在迴圈裡面使用defer

不要在迴圈裡面使用defer,除非你真的確定defer的工作流程,例如:

只有當函式返回時defer的函式才會被執行,如果在for迴圈裡面defer定義的函式會不斷的壓棧,可能會爆棧而導致程式異常。

解決方法1:將defer移動到迴圈之外

解決方法2:構造一層新的函式包裹defer

defer方法

沒有指標的情況:

type Car struct {
  model string
}
func (c Car) PrintModel() {
  fmt.Println(c.model)
}
func main() {
  c := Car{model: "DeLorean DMC-12"}
  defer c.PrintModel()
  c.model = "Chevrolet Impala"
}

程式輸出DeLorean DMC-12。根據我們前面講的內容,defer的時候會把函式和參考拷貝一份儲存起來,所以c.model的值後面改變也不會影響defer的執行。

有指標的情況:

Car PrintModel()方法定義改為:

func (c *Car) PrintModel() {
  fmt.Println(c.model)
}

程式將會輸出Chevrolet Impala。這些defer雖然將函式和引數儲存了起來,但是由於引數的值本身是針對,隨意後面的改動會影響到defer函式的行為。

同理的例子還有:

for i := 0; i < 3; i++ {
  defer func() {
   fmt.Println(i)
  }()
}

程式將會輸出:

3
3
3

因為閉包引用匿名函式外面的變數相當於是指標引用,得到的是變數的地址,實際到defer真正執行時,指標指向的內容已經發生的變化:

解決的方法:

for i := 0; i < 3; i++ {
  defer func(i int) {
   fmt.Println(i)
  }(i)
}

或者:

for i := 0; i < 3; i++ {
  defer fmt.Println(i)
}

程式輸出:

2
1
0

這裡就不會用到閉包的上下文引用特性,是正經的函式引數拷貝傳遞,所以不會有問題。

defer中修改函式error返回值

package main

import (
    "errors"
    "fmt"
)

func main() {
    {
        err := release()
        fmt.Println(err)
    }

    {
        err := correctRelease()
        fmt.Println(err)
    }
}

func release() error {
    defer func() error {
        return errors.New("error")
    }()

    return nil
}

func correctRelease() (err error) {
    defer func() {
        err = errors.New("error")
    }()
    return nil
}

release函式中error的值並不會被defer的return返回,因為匿名返回值在defer執行前就已經宣告好並複製為nil。correctRelease函式能夠修改返回值是因為閉包的特性,defer中的err是實際的返回值err地址引用,指向的是同一個變數。defer修改程式返回值error一般用在和recover搭配中,上述的情況屬於濫用defer的一種情況,其實error函式值可以直接在程式的return中修改,不用defer。

總結

文章介紹了defer、panic和recover的原理和用法,並且在最後給出了一些在實際應用的實踐建議,不要濫用defer,注意defer搭配閉包時的一些特性。

參考

https://blog.golang.org/defer-panic-and-recover

https://blog.learngoprogramming.com/gotchas-of-defer-in-go-1-8d070894cb01

https://blog.learngoprogramming.com/5-gotchas-of-defer-in-go-golang-part-ii-cc550f6ad9aa

https://blog.learngoprogramming.com/golang-defer-simplified-77d3b2b817ff

https://blog.learngoprogramming.com/5-gotchas-of-defer-in-go-golang-part-iii-36a1ab3d6ef1

 

 

 

 

 

 

 

相關文章