Golang Recover的一個小坑

劍塵發表於2020-12-19

1.error

Golang被詬病非常多的一點就是缺少強大方便的異常處理機制,大部分高階程式語言,比如Java、PHP、Python等都擁有一種try catch機制,這種異常捕獲機制可以非常方便的處理程式執行中可能出現的各種意外情況。

嚴格來說,在Go裡面,錯誤和異常是2種不同的型別,錯誤一般是指程式產生的邏輯錯誤,或者意料之中的意外情況,而且異常一般就是panic,比如角標越界、段錯誤。

對於錯誤,Golang採用了一種非常原始的手段,我們必須手動處理可能產生的每一個錯誤,一般會把錯誤返回給呼叫方,下面這種寫法在Go裡面十分常見:

package main
import (
    "errors"
    "fmt"
)

func main() {
    s, err := say()
    if err != nil {
        fmt.Printf("%s\n", err.Error())
    } else {
        fmt.Printf("%s\n", s)
    }
}

func say() (string, error) {
    // do something
    return "", errors.New("something error")
}
複製程式碼

這種寫法最大的問題就是每一個error都需要判斷處理,非常繁瑣,如果使用try catch機制,我們就可以統一針對多個函式呼叫可能產生的錯誤做處理,節省一點程式碼和時間。不過我們們今天不是來討論Go的異常錯誤處理機制的,這裡只是簡單說一下。

2.panic

一般錯誤都是顯示的,程式明確返回的,而異常往往是隱示的,不可預測的,比如下面的程式碼:

package main

import "fmt"

func main() {
    fmt.Printf("%d\n", cal(1,2))
    fmt.Printf("%d\n", cal(5,2))
    fmt.Printf("%d\n", cal(5,0)) //panic: runtime error: integer divide by zero 
    fmt.Printf("%d\n", cal(9,5))
}

func cal(a, b int) int {
    return a / b
}
複製程式碼

在執行第三個計算的時候會發生一個panic,這種錯誤會導致程式退出,下面的程式碼的就無法執行了。當然你可以說這種錯誤理論上是可以預測的,我們只要在cal函式內部做好處理就行了。

然而實際開發中,會發生panic的地方可能特別多,而且不是這種一眼就能看出來的,在Web服務中,這樣的panic會導致整個Web服務掛掉,特別危險。

3.recover

雖然沒有try catch機制,Go其實有一種類似的recover機制,功能弱了點,用法很簡單:

package main

import "fmt"

func main() {
    fmt.Printf("%d\n", cal(1, 2))
    fmt.Printf("%d\n", cal(5, 2))
    fmt.Printf("%d\n", cal(5, 0))
    fmt.Printf("%d\n", cal(9, 2))
}

func cal(a, b int) int {
    defer func() {
        if err := recover(); err != nil {
            fmt.Printf("%s\n", err)
        }
    }()
    return a / b
}
複製程式碼

首先,大家得理解defer的作用,簡單說defer就類似於物件導向裡面的解構函式,在這個函式終止的時候會執行,即使是panic導致的終止。

所以,在cal函式裡面每次終止的時候都會檢查有沒有異常產生,如果產生了我們可以處理,比如說記錄日誌,這樣程式還可以繼續執行下去。

4.注意的坑

一般defer recover這種機制經常用在常駐程式的應用,比如Web服務,在Go裡面,每一個Web請求都會分配一個goroutine去處理,在沒有做任何處理的情況下,假如某一個請求發生了panic,就會導致整個服務掛掉,這是不可接受的,所以在Web應用裡面必須使用recover保證即使某一個請求發生錯誤也不影響其它請求。

這裡我使用一小段程式碼模擬一下:

package main

import (
    "fmt"
)

func main() {
    requests := []int{12, 2, 3, 41, 5, 6, 1, 12, 3, 4, 2, 31}
    for n := range requests {
        go run(n) //開啟多個協程
    }

    for {
        select {}
    }
}

func run(num int) {
    //模擬請求錯誤
    if num%5 == 0 {
        panic("請求出錯")
    }
    fmt.Printf("%d\n", num)
}
複製程式碼

上面這段程式碼無法完整執行下去,因為其中某一個協程必然會發生panic,從而導致整個應用掛掉,其它協程也停止執行。

解決方法和上面一樣,我們只需要在run函式裡面加入defer recover,整個程式就會非常健壯,即使發生panic,也會完整的執行下去。

func run(num int) {
    defer func() {
        if err := recover();err != nil {
            fmt.Printf("%s\n", err)
        }
    }()
    if num%5 == 0 {
        panic("請求出錯")
    }
    fmt.Printf("%d\n", num)
}
複製程式碼

上面的程式碼只是演示,真正的坑是:如果你在run函式裡面又啟動了其它協程,這個協程發生的panic是無法被recover的,還是會導致整個程式掛掉,我們改造了一下上面的例子:

func run(num int) {
    defer func() {
        if err := recover(); err != nil {
            fmt.Printf("%s\n", err)
        }
    }()
    if num%5 == 0 {
        panic("請求出錯")
    }
    go myPrint(num)
}

func myPrint(num int) {
    if num%4 == 0 {
        panic("請求又出錯了")
    }
    fmt.Printf("%d\n", num)
}
複製程式碼

我在run函式裡面又通過協程的方式呼叫了另一個函式,而這個函式也會發生panic,你會發現整個程式也掛了,即使run函式有recover也沒有任何作用,這意味著我們還需要在myPrint函式裡面加入recover。但是如果你不使用協程的方式呼叫myPrint函式,直接呼叫的話還是可以捕獲recover的。

總結一下就是defer recover這種機制只是針對當前函式和以及直接呼叫的函式可能產生的panic,它無法處理其呼叫產生的其它協程的panic,這一點和try catch機制不一樣。

理論上講,所有使用協程的地方都必須做defer recover處理,這樣才能保證你的應用萬無一失,不過開發中可以根據實際情況而定,對於一些不可能出錯的函式加了還影響效能。

Go的Web服務也是一樣,預設的recover機制只能捕獲一層,如果你在這個請求的處理中又使用了其它協程,那麼必須非常慎重,畢竟只要發生一個panic,整個Web服務就會掛掉。

最後,總結一下,Go的異常處理機制雖然沒有很多其它語言高效,但是基本上還是能滿足需求,目前官方已經在著完善這一點,Go2可能會見到。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章