Go 錯誤處理指北:Defer、Panic、Recover 三劍客

江湖十年發表於2024-10-28

首發地址:https://mp.weixin.qq.com/s/FRa0A51DGQ6MiKO6PUu6wQ

Go 語言中的錯誤處理不僅僅只有 if err != nildeferpanicrecover 這三個相對來說不不如 if err != nil 有名氣的控制流語句,也與錯誤處理息息相關。本文就來講解下這三者在 Go 語言中的應用。

Defer

defer 是一個 Go 中的關鍵字,通常用於簡化執行各種清理操作的函式。defer 後跟一個函式(或方法)呼叫,該函式(或方法)的執行會被推遲到外層函式返回的那一刻,即函式(或方法)要麼遇到了 return,要麼遇到了 panic

語法

defer 功能使用語法如下:

defer Expression

其中 Expression 必須是函式或方法的呼叫。

defer 使用示例如下:

func f() {
    defer fmt.Println("deferred in f")
    fmt.Println("calling f")
}

func main() {
    f()
}

執行示例程式碼,得到輸出如下:

$ go run main.go            
calling f
deferred in f

根據輸出可以發現,被 defer 修飾的 fmt.Println("deferred in f") 呼叫並沒有立即執行,而是先執行了 fmt.Println("calling f"),然後才會執行 defer 修飾的函式呼叫語句。

執行順序

一個函式中可以寫多個 defer 語句:

func f() {
    defer fmt.Println("deferred in f 1")
    defer fmt.Println("deferred in f 2")
    defer fmt.Println("deferred in f 3")
    fmt.Println("calling f")
}

執行示例程式碼,得到輸出如下:

$ go run main.go
calling f
deferred in f 3
deferred in f 2
deferred in f 1

defer 修飾的函式呼叫,在外層函式返回後按後進先出順序執行,即 Last In First Out(LIFO)。

不僅如此,defer 可以寫在任意位置,並且還可以巢狀,即在被 defer 修飾的函式中再次使用 defer

示例如下:

func f() {
    fmt.Println("1")

    defer func() {
        fmt.Println("2")
        defer fmt.Println("3")
        fmt.Println("4")
    }()

    fmt.Println("5")

    defer fmt.Println("6")

    fmt.Println("7")
}

執行示例程式碼,得到輸出如下:

$ go run main.go
1
5
7
6
2
4
3

這個輸出結果符合你的預期嗎?

先看外層函式 f 的程式碼邏輯,有兩個 defer 語句,無論位置在哪,defer 都會使函式呼叫延遲執行,所以先輸出了 157

然後根據 LIFO 原則,先執行第 2 個 defer 語句所修飾的函式呼叫,所以輸出 6

接著執行第 1 個 defer 語句所修飾的函式呼叫,其內部同樣會按順序執行沒有被 defer 語句修飾的程式碼,所以先輸出 24,然後執行 defer 語句所修飾的函式呼叫,輸出 3

讀寫函式返回值

有時候,我們可以使用 defer 語句來讀取或修改函式的返回值。

有如下示例,試圖在 defer 中修改函式的返回值:

func f() int {
    r := 2
    defer func() {
        fmt.Println("r:", r)
        r *= 3
    }()
    return r
}

func main() {
    fmt.Println(f())
}

執行示例程式碼,得到輸出如下:

$ go run main.go
r: 2
2

看來沒有成功。

函式使用具名返回值再來看看:

func f() (r int) {
    r = 2
    defer func() {
        fmt.Println("r:", r)
        r *= 3
    }()
    return r
}

執行示例程式碼,得到輸出如下:

$ go run main.go
r: 2
6

這次成功了。

如果改成這樣呢:

func f() (r int) {
    defer func() {
        fmt.Println("r:", r)
        r *= 3
    }()
    return 2
}

現在,返回值直接寫成了 2,而非變數 r

執行示例程式碼,得到輸出如下:

$ go run main.go
r: 2
6

這次返回值依然修改成功了。

前面幾個示例,其實都算使用了閉包。因為被 defer 修飾的函式內部都引用了外部變數 r

我們再看一個不使用閉包的示例:

func f() (r int) {
    defer func(r int) {
        fmt.Println("r:", r)
        r *= 3
    }(r)
    return 2
}

執行示例程式碼,得到輸出如下:

$ go run main.go
r: 0
2

這次返回值沒有修改成功,並且被 defer 修飾的函式內部讀到的 r 值為 0,並不是前面示例中的 2

也就是說,實際上雖然被 defer 修飾的函式呼叫會延遲執行,但是我們傳遞給函式的引數,會被立即求值

我們接著看下面這個示例:

func f() (r int) {
    x := 2
    defer func() {
        fmt.Println("r:", r)
        fmt.Println("x:", x)
        x *= 3
    }()
    return x
}

執行示例程式碼,得到輸出如下:

$ go run main.go
r: 2
x: 2
2

當程式碼執行到 return x 時,r 值也會被賦值為 2,這沒什麼好解釋的。

然後在 defer 所修飾的函式內部,我們只修改了 x 變數,這對返回結果 r 沒有影響。

把函式返回值型別改成指標試試呢:

func f() (r *int) {
    x := 2
    defer func() {
        fmt.Println("r:", *r)
        fmt.Println("x:", x)
        x *= 3
    }()
    return &x
}

func main() {
    fmt.Println(*f())
}

執行示例程式碼,得到輸出如下:

$ go run main.go
r: 2
x: 2
6

這次返回值又成功被修改了。

看到這裡,你是不是對 defer 語句的效果有點懵,沒關係,我們再來梳理下 defer 執行時機。

defer 語句的行為其實是可預測的,我們可以記住這三條規則

  1. 在計算 defer 語句時,將立即計算被 defer 修飾的函式引數。
  2. defer 修飾的函式,在外層函式返回後按後進先出的順序(LIFO)執行。
  3. 延遲函式可以讀取或賦值給外層函式的具名返回值。

現在,你再翻回去重新看看上面的幾個示例程式,是不是都能理解了呢?

釋放資源

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 執行失敗,函式返回後 src 並沒有被關閉。

而這種場景剛好適用 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)
}

此時如果 os.Create 執行失敗,函式返回後 defer src.Close() 將會被執行,檔案資源得以釋放。

切記,不要在 if err != nil 之前呼叫 defer 釋放資源,這很可能會觸發 panic

src, err := os.Open(srcName)
defer src.Close()
if err != nil {
    return
}

因為,如果呼叫 os.Open 報錯,src 值將為 nil,而 nil.Close() 會觸發 panic,導致程式意外終止而退出。

此外,在處理釋放資源的情況,你可能寫出如下程式碼:

type fakeFile struct {
    name string
}

func (f *fakeFile) Close() error {
    fmt.Println("close:", f)
    return nil
}

// 錯誤寫法:f 變數的值最終是 f2,所以 f2 會被關閉兩次,f1 沒關閉
func processFile() {
    f := fakeFile{name: "f1"}
    defer f.Close()

    f = fakeFile{name: "f2"}
    defer f.Close()

    fmt.Println("calling processFile")
    return
}

func main() {
    processFile()
}

執行示例程式碼,得到輸出如下:

$ go run main.go
calling processFile
close: &{f2}
close: &{f2}

可以發現,在函式 processFile 中,因為 f 被重複賦值,導致 f 變數的值最終是 f2,所以 f2 會被關閉兩次,f1 並沒有被關閉。

還記得我們前面講過的規則嗎:在計算 defer 語句時,將立即計算被 defer 修飾的函式引數

所以,我們可以在 defer 處讓變數 f 先被計算出來:

func processFile1() {
    f := fakeFile{name: "f1"}
    defer func(f fakeFile) {
        f.Close()
    }(f)

    f = fakeFile{name: "f2"}
    defer func(f fakeFile) {
        f.Close()
    }(f)

    fmt.Println("calling processFile1")
    return
}

這樣就解決了問題。

當然,更簡單的方式是我們壓根就不要使用同一個變數來表示不同的檔案物件:

func processFile2() {
    f1 := fakeFile{name: "f1"}
    defer f1.Close()

    f2 := fakeFile{name: "f2"}
    defer f2.Close()

    fmt.Println("calling processFile2")
    return
}

不過,有時候在在 for 迴圈中,就是會出現 f 被重複賦值的情況,在 for 迴圈中使用 defer 語句,我們可能還會踩到類似的坑,所以你一定要小心。

WithClose

文章讀到這裡,想必你也看出來了,defer 功能正是對標了 Python 中的 try...finally 或者 with 語句的效果。

Python 的 with 語法非常優雅,如何使用 defer 實現近似效果呢?

你可以在我的另一篇文章《在 Go 中如何實現類似 Python 中的 with 上下文管理器》中找到答案。

篇幅所限,我就不在這裡再廢話連篇的講一遍了。

如果你想用下面這種單獨的程式碼塊作用域來實現:

func f() {
    {
        // defer 函式一定是在函式退出時才會執行,而不是程式碼塊退出時執行
        defer fmt.Println("defer done")
        fmt.Println("code block")
    }

    fmt.Println("calling f")
}

很遺憾的告訴你,這並不能達到想要的效果,你可以思考後再點選我的另一篇文章來對比下你我二人的實現是否相同。

結構體方法是否使用指標接收者

當結構體方法使用指標作為接收者時,也要小心。

示例如下:

type User struct {
    name string
}

func (u User) Name() {
    fmt.Println("Name:", u.name)
}

func (u *User) PointName() {
    fmt.Println("PointName:", u.name)
}

func printUser() {
    u := User{name: "user1"}

    defer u.Name()
    defer u.PointName()

    u.name = "user2"
}

func main() {
    printUser()
}

執行示例程式碼,得到輸出如下:

$ go run main.go
PointName: user2
Name: user1

User.Name 方法接收者為結構體,在 defer 中被呼叫,最終輸出結果為初始 nameuser1

User.PointName 方法接收者為指標,在 defer 中被呼叫,最終輸出結果為修改後的 nameuser2

可見,defer 處不僅會計算函式引數,其實它會對其後面的表示式求值,並計算出最終將要執行的函式或方法。

也就是說,程式碼執行到 defer u.Name() 時,變數 u 的值就已經計算出來了,相當於“複製”了一個新的變數,後面再透過 u.name = "user2" 修改其屬性,二者已經不是同一個變數了。

而程式碼執行到 defer u.PointName() 時,其實這裡的 u 是指標型別,即使“複製”了一個新的變數,其內部儲存的指標依然相等,所以可以被修改。

如果將程式碼修改成如下這樣,執行結果又會怎樣呢?

func printUser() {
    u := User{name: "user1"}

    defer func() {
        u.Name()
        u.PointName()
    }()

    u.name = "user2"
}

這個就交給你自己去實驗了。

當 defer 遇到 os.Exit

defer 遇到 os.Exit 時會怎樣呢?

func f() {
    defer fmt.Println("deferred in f")
    fmt.Println("calling f")
    os.Exit(0)
}

func main() {
    f()
}

執行示例程式碼,得到輸出如下:

$ go run main.go
calling f

可見,當遇到 os.Exit 時,程式直接退出,defer 並不會被執行,這一點平時開發過程中要格外注意。

一個過時的面試題

前幾年,有一個考察 defer 的面試題經常在網上出現:

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

問執行 f 以後,輸出什麼?

既然會成為面試題,執行結果就肯定有貓膩。

如果你使用 Go 1.22 以前的版本執行示例程式碼,將得到如下結果:

$ go run main.go
3
3
3

而如果你使用 Go 1.22 及以後的版本執行示例程式碼,將得到如下結果:

$ go run main.go
2
1
0

這是由於,在 Go 1.22 以前,由 for 迴圈宣告的變數只會被建立一次,並在每次迭代時更新。在 Go 1.22 中,迴圈的每次迭代都會建立新的變數,以避免意外的共享錯誤

這在 Go 1.22 Release Notes 中有說明。

在舊版本的 Go 中要修復這個問題,只需要這樣寫即可:

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

直接把 defer 放在外面,不要構成閉包。

又或者為 defer 函式增加引數:

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

總之,解決方案就是不要出現閉包。

不要出現 defer nil 的情況

前文說過,defer 後面支援函式或方法的呼叫。

但是,如果計算 defer 後的表示式出現 nil 的情況,則會觸發 panic

func deferNil() {
    var f func()
    defer f()
    fmt.Println("calling deferNil")
}

func main() {
    deferNil()
}

執行示例程式碼,得到輸出如下:

calling deferNil
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x0 pc=0x10264f88c]

goroutine 1 [running]:
main.deferNil()
        /go/blog-go-example/error/defer-panic-recover/defer/main.go:363 +0x6c
main.main()
        /go/blog-go-example/error/defer-panic-recover/defer/main.go:384 +0x1c
exit status 2

因為 nil 不可被呼叫。

至於到底什麼是 panic,咱們往下看。

Panic

在 Go 中,error 表示一個錯誤,錯誤通常會返給呼叫方,交由呼叫方來決定如何處理。而 panic 則表示一個無法挽回的異常,panic 會直接終止當前執行的控制流。

panic 是一個內建函式,它會停止程式的正常控制流並輸出 panic 相關資訊。

有兩種方式可以觸發 panic,一種是非法操作導致執行時錯誤,比如訪問陣列索引越界,此時會觸發執行時 panic。另一種是主動呼叫 panic 函式。

當在函式 F 中呼叫了 panic 後,程式執行流程如下:

函式 F 呼叫 panic 時,F 的執行會被停止,接下來會執行 F 中呼叫 panic 之前的所有 defer 函式,然後 F 返回給呼叫者。

接著,對於 F 的呼叫方 G 的行為也類似於對 panic 的呼叫。

該過程繼續向上返回,直到當前 goroutine 中的所有函式都返回,此時程式崩潰。

最後,你將在執行 Go 程式的控制檯看到程式執行異常的堆疊資訊。

使用

panic 使用示例如下:

func f() {
    defer fmt.Println("defer 1")
    fmt.Println(1)
    panic("woah")
    defer fmt.Println("defer 2")
    fmt.Println(2)
}

func main() {
    f()
}

執行示例程式碼,得到輸出如下:

$ go run main.go
1
defer 1
panic: woah

goroutine 1 [running]:
main.f()
        /go/blog-go-example/error/defer-panic-recover/panic/main.go:10 +0xa0
main.main()
        /go/blog-go-example/error/defer-panic-recover/panic/main.go:29 +0x1c
exit status 2

可以發現,panic 會輸出異常堆疊資訊。

並且 1defer 1 都被輸出了,而 2defer 2 沒有輸出,說明 panic 呼叫之後的程式碼不會執行,但它不影響 panic 之前 defer 函式的執行。

此外,如果你足夠細心,還可以發現 panic 後程式的退出碼為 2

子 Goroutine 中 panic

如果在子 goroutine 中發生 panic,也會導致主 goroutine 立即退出:

func g() {
    fmt.Println("calling g")
    // 子 goroutine 中發生 panic,主 goroutine 也會退出
    go f(0)
    fmt.Println("called g")
}

func f(i int) {
    fmt.Println("panicking!")
    panic(fmt.Sprintf("i=%v", i))
    fmt.Println("printing in f", i) // 不會被執行
}

func main() {
    g()
    time.Sleep(10 * time.Second)
}

執行示例程式碼,程式並不會等待 10s 後才退出,而是立即 panic 並退出,得到輸出如下:

$ go run main.go
calling g
called g
panicking!
panic: i=0

goroutine 3 [running]:
main.f(0x0)
        /go/blog-go-example/error/defer-panic-recover/panic/main.go:25 +0xa0
created by main.g in goroutine 1
        /go/blog-go-example/error/defer-panic-recover/panic/main.go:19 +0x5c
exit status 2

panic 和 os.Exit

雖然 panicos.Exit 都能使程式終止並退出,但它們有著顯著的區別,尤其在觸發時的行為和對程式流程的影響上。

panic 用於在程式中出現異常情況時引發一個執行時錯誤,通常會導致程式崩潰(除非被 recover 恢復)。當觸發 panic 時,defer 語句仍然會執行。panic 還會列印詳細的堆疊資訊,顯示引發錯誤的呼叫鏈。panic 退出狀態碼固定為 2

os.Exit 會立即終止程式,並返回指定的狀態碼給作業系統。當執行 os.Exit 時,defer 語句不會執行。os.Exit 直接通知作業系統退出程式,它不會返回給呼叫者,也不會引發執行時堆疊追蹤,所以也就不會列印堆疊資訊。os.Exit 可以設定程式退出狀態碼。

因為 panic 比較暴力,所以一般只建議在 main 函式中使用,比如應用的資料庫初始化失敗後直接 panic,因為程式無法連線資料庫,程式繼續執行意義不大。而普通函式中推薦儘量返回 error 而不是直接 panic

不過 panic 也不是沒有挽救的餘地,recover 就是來恢復 panic 的。

Recover

recover 也是一個函式,用來從 panic 所導致的程式崩潰中恢復執行。

使用

recover 使用示例如下:

func f() {
    defer func() {
        recover()
    }()
    
    defer fmt.Println("defer 1")
    fmt.Println(1)
    panic("woah")
    defer fmt.Println("defer 2")
    fmt.Println(2)
}

func main() {
    f()
}

執行示例程式碼,得到輸出如下:

$ go run main.go
1
defer 1

recover() 的呼叫捕獲了 panic 觸發的異常,並且程式正常退出。

recover 函式只在 defer 語句的上下文中才有效,直接呼叫的話,只會返回 nil

如下兩種方式都是錯誤的用法:

recover()
defer recover()

可見,recover 必須與 defer 一同使用,來從 panic 中恢復程式。不過 panic 之後的程式碼依舊不會執行,recover() 呼叫後只會執行 defer 語句中的剩餘程式碼。

下面這個例子將會捕獲到 panic,並且輸出 panic 資訊:

func f() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recover:", r)
        }
    }()
    panic("woah")
}

執行示例程式碼,得到輸出如下:

$ go run main.go
recover: woah

可以發現,recover 函式的返回值,正是 panic 函式的引數。

不要在 defer 中出現 panic

為了避免不必要的麻煩,defer 函式中最好不要有能夠引起 panic 的程式碼。

正常來說,defer 用來釋放資源,不會出現大量程式碼。如果 defer 函式中邏輯過多,則需要斟酌下有沒有更優解。

如下示例將輸出什麼?

func f() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recover:", r)
        }
    }()

    defer func() {
        panic("woah 1")
    }()
    panic("woah 2")
}

執行示例程式碼,得到輸出如下:

$ go run main.go
recover: woah 1

看來,defer 中的 panic("woah 1") 覆蓋了程式正常控制流中的 panic("woah 2")

如果我們將程式碼順序稍作修改:

func f() {
    defer func() {
        panic("woah 1")
    }()

    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recover:", r)
        }
    }()

    panic("woah 2")
}

執行示例程式碼,得到輸出如下:

$ go run main.go
recover: woah 2
panic: woah 1

goroutine 1 [running]:
main.f.func1()
        /go/blog-go-example/error/defer-panic-recover/recover/main.go:68 +0x2c
main.f()
        /go/blog-go-example/error/defer-panic-recover/recover/main.go:77 +0x68
main.main()
        /go/blog-go-example/error/defer-panic-recover/recover/main.go:142 +0x1c
exit status 2

看來,呼叫 recoverdefer 應該放在函式的入口處,成為第一個 defer

recover 只能捕獲當前 Goroutine 中的 panic

需要額外注意的一點是,recover 只會捕獲當前 goroutine 所觸發的 panic

示例如下:

func f() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recover:", r)
        }
    }()

    go func() {
        panic("woah")
    }()
    time.Sleep(1 * time.Second)
}

執行示例程式碼,得到輸出如下:

$ go run main.go
panic: woah

goroutine 18 [running]:
main.f.func2()
        /go/blog-go-example/error/defer-panic-recover/recover/main.go:91 +0x2c
created by main.f in goroutine 1
        /go/blog-go-example/error/defer-panic-recover/recover/main.go:90 +0x40
exit status 2

goroutine 中觸發的 panic 並沒有被 recover 捕獲。

所以,如果你認為程式碼中需要捕獲 panic 時,就需要在每個 goroutine 中都執行 recover

將 panic 轉換成 error 返回

有時候,我們可能需要將 panic 轉換成 error 並返回,防止當前函式呼叫他人提供的不可控程式碼時出現意外的 panic

func g(i int) (number int, err error) {
    defer func() {
        if r := recover(); r != nil {
            var ok bool
            err, ok = r.(error)
            if !ok {
                err = fmt.Errorf("f returns err: %v", r)
            }
        }
    }()

    number, err = f(i)
    return number, err
}

func f(i int) (int, error) {
    if i == 0 {
        panic("i=0")
    }
    return i * i, nil
}

func main() {
    fmt.Println(g(1))
    fmt.Println(g(0))
}

執行示例程式碼,得到輸出如下:

$ go run main.go
1 <nil>
0 f returns err: i=0

net/http 使用 recover 優雅處理 panic

我們在開發 HTTP Server 程式時,即使某個請求遇到了 panic 也不應該使整個程式退出。所以,就需要使用 recover 來處理 panic

來看一個使用 net/http 建立的 HTTP Server 程式示例:

package main

import (
    "fmt"
    "log"
    "net/http"
    "os"
)

func handler(w http.ResponseWriter, r *http.Request) {
    if r.URL.Path == "/panic" {
        panic("url is error")
    }
    // 列印請求的路徑
    fmt.Fprintf(w, "Hello, you've requested: %s\n", r.URL.Path)
}

func main() {
    // 建立一個日誌例項,寫到標準輸出
    logger := log.New(os.Stdout, "http: ", log.LstdFlags)

    // 自定義 HTTP Server
    server := &http.Server{
        Addr:     ":8080",
        ErrorLog: logger, // 設定日誌記錄器
    }

    // 註冊處理函式
    http.HandleFunc("/", handler)

    // 啟動伺服器
    fmt.Println("Starting server on :8080")
    if err := server.ListenAndServe(); err != nil {
        logger.Println("Server failed to start:", err)
    }
}

啟動示例,程式會阻塞在這裡等待請求進來:

$ go run main.go
Starting server on :8080

使用 curl 命令分別對 HTTP Server 傳送三次請求:

$ curl localhost:8080
Hello, you've requested: /
$ curl localhost:8080/panic
curl: (52) Empty reply from server
$ curl localhost:8080/hello
Hello, you've requested: /hello

可以發現,在請求 /panic 路由時,HTTP Server 觸發了 panic 並返回了空內容,然後第三個請求依然能夠得到正確的響應。

可見 HTTP Server 並沒有退出。

現在回去看一下執行 HTTP Server 的控制檯日誌:

Starting server on :8080
http: 2024/10/13 23:08:28 http: panic serving [::1]:50547: url is error
goroutine 34 [running]:
net/http.(*conn).serve.func1()
        /go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/net/http/server.go:1947 +0xb0
panic({0x10114c000?, 0x1011a4ba8?})
        /go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/runtime/panic.go:785 +0x124
main.handler({0x1011a8178?, 0x140001440e0?}, 0x1400010bb28?)
        /workspace/projects/go/blog-go-example/error/defer-panic-recover/recover/http/main.go:12 +0x130
net/http.HandlerFunc.ServeHTTP(0x101348320?, {0x1011a8178?, 0x140001440e0?}, 0x1010999e4?)
        /go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/net/http/server.go:2220 +0x38
net/http.(*ServeMux).ServeHTTP(0x0?, {0x1011a8178, 0x140001440e0}, 0x14000154140)
        /go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/net/http/server.go:2747 +0x1b4
net/http.serverHandler.ServeHTTP({0x1400011ade0?}, {0x1011a8178?, 0x140001440e0?}, 0x6?)
        /go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/net/http/server.go:3210 +0xbc
net/http.(*conn).serve(0x140000a4120, {0x1011a8678, 0x1400011acf0})
        /go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/net/http/server.go:2092 +0x4fc
created by net/http.(*Server).Serve in goroutine 1
        /go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/net/http/server.go:3360 +0x3dc

panic 資訊 url is error 被輸出了,並且列印了堆疊資訊。

不過這 HTTP Server 依然在執行,並能提供服務。

這其實就是在 net/http 中使用了 recover 來處理 panic

我們可以看下 http.Server.Serve 的原始碼:

func (srv *Server) Serve(l net.Listener) error {
    ...

    ctx := context.WithValue(baseCtx, ServerContextKey, srv)
    for {
        rw, err := l.Accept()
        if err != nil {
            ...
            return err
        }
        connCtx := ctx
        if cc := srv.ConnContext; cc != nil {
            connCtx = cc(connCtx, rw)
            if connCtx == nil {
                panic("ConnContext returned nil")
            }
        }
        tempDelay = 0
        c := srv.newConn(rw)
        c.setState(c.rwc, StateNew, runHooks) // before Serve can return
        go c.serve(connCtx)
    }
}

可以發現,在 for 迴圈中,每接收到一個請求都會交給 go c.serve(connCtx) 開啟一個新的 goroutine 來處理。

那麼在 serve 方法中就一定會有 recover 語句:

// Serve a new connection.
func (c *conn) serve(ctx context.Context) {
    if ra := c.rwc.RemoteAddr(); ra != nil {
        c.remoteAddr = ra.String()
    }
    ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr())
    var inFlightResponse *response
    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 inFlightResponse != nil {
            inFlightResponse.cancelCtx()
            inFlightResponse.disableWriteContinue()
        }
        if !c.hijacked() {
            if inFlightResponse != nil {
                inFlightResponse.conn.r.abortPendingRead()
                inFlightResponse.reqBody.Close()
            }
            c.close()
            c.setState(c.rwc, StateClosed, runHooks)
        }
    }()

    ...
}

果然,在 serve 方法原始碼中發現了 defer + recover 的組合。

並且這行程式碼:

c.server.logf("http: panic serving %v: %v\n%s", c.remoteAddr, err, buf)

可以在執行 HTTP Server 的控制檯日誌中得到印證:

http: 2024/10/13 23:08:28 http: panic serving [::1]:50547: url is error

panic(nil)

panic 函式簽名如下:

func panic(v any)

既然 panic 引數是 any 型別,那麼 nil 當然也可以作為引數。

可以寫出 panic(nil) 程式示例程式碼如下:

func f() {
    defer func() {
        if err := recover(); err != nil {
            fmt.Println(err)
        }
    }()
    panic(nil)
}

執行示例程式碼,得到輸出如下:

$ go run main.go
panic called with nil argument

這沒什麼問題。

但是在 Go 1.21 版本以前,執行上述程式碼,將得到如下結果:

$ go run main.go

你沒看錯,我也沒寫錯誤,這裡什麼都沒輸出。

在舊版本的 Go 中,panic(nil) 並不能被 recover 捕獲,recover() 呼叫結果將返回 nil

你可以在 issues/25448 中找到關於此問題的討論。

幸運的是,在 Go 1.21 釋出時,這個問題得以解決。

不過,這就破壞了 Go 官方承諾的 Go1 相容性保障。因此,Go 團隊又提供了 GODEBUG=panicnil=1 標識來恢復舊版本中的 panic 行為。

使用方式如下:

$ GODEBUG=panicnil=1 go run main.go

其實,根據 panic 宣告中的註釋我們也能夠觀察到 Go 1.21 後 panic(nil) 行為有所改變:

// Starting in Go 1.21, calling panic with a nil interface value or an
// untyped nil causes a run-time error (a different panic).
// The GODEBUG setting panicnil=1 disables the run-time error.
func panic(v any)

panic 相關原始碼實現如下:

// The implementation of the predeclared function panic.
func gopanic(e any) {
    if e == nil {
        if debug.panicnil.Load() != 1 {
            e = new(PanicNilError)
        } else {
            panicnil.IncNonDefault()
        }
    }
...
}

在沒有指定 GODEBUG=panicnil=1 情況下,panic(nil) 呼叫等價於 panic(new(runtime.PanicNilError))

資料庫事務

使用 defer + recover 來處理資料庫事務,也是比較常用的做法。

這裡有一個來自 GORM 官方文件中的 示例程式

type Animal struct {
    Name string
}

func CreateAnimals(db *gorm.DB) error {
    tx := db.Begin()
    defer func() {
        if r := recover(); r != nil {
            tx.Rollback()
        }
    }()

    if err := tx.Error; err != nil {
        return err
    }

    if err := tx.Create(&Animal{Name: "Giraffe"}).Error; err != nil {
        tx.Rollback()
        return err
    }

    if err := tx.Create(&Animal{Name: "Lion"}).Error; err != nil {
        tx.Rollback()
        return err
    }

    return tx.Commit().Error
}

在函式最開始開啟了一個事務,接著使用 defer + recover 來確保程式執行中間過程遇到 panic 時能夠回滾事務。

程式執行過程中使用 tx.Create 建立了兩條 Animal 資料,並且如果輸出,都將回滾事務。

如果沒有錯誤,最終呼叫 tx.Commit() 提交事務,並將其錯誤結果返回。

這個函式實現邏輯非常嚴謹,沒什麼問題。

但是這個示例程式碼寫的過於囉嗦,還有最佳化的空間,可以寫成這樣:

func CreateAnimals(db *gorm.DB) error {
    tx := db.Begin()
    defer tx.Rollback()

    if err := tx.Error; err != nil {
        return err
    }

    if err := tx.Create(&Animal{Name: "Giraffe"}).Error; err != nil {
        return err
    }

    if err := tx.Create(&Animal{Name: "Lion"}).Error; err != nil {
        return err
    }

    return tx.Commit().Error
}

這裡在 defer 中直接去掉了 recover 的判斷,所以無論如何程式最終都會執行 tx.Rollback()

之所以可以這樣寫,是因為呼叫 tx.Commit() 時事務已經被提交成功,之後執行 tx.Rollback() 並不會影響已經提交事務。

這段程式碼看上去要簡潔不少,不必在每次出現 error 時都想著呼叫 tx.Rollback() 回滾事務。

你可能認為這樣寫有損程式碼效能,但其實絕大多數場景下我們不需要擔心。我更願意用一點點可以忽略不計的效能損失,換來一段清晰的程式碼,畢竟可讀性很重要。

panic 並不是都可以被 recover 捕獲

最後,咱們再來看一個併發寫 map 的場景,如果觸發 panic 結果將會怎樣?

示例如下:

func f() {
    m := map[int]struct{}{}

    go func() {
        defer func() {
            if err := recover(); err != nil {
                fmt.Println("goroutine 1", err)
            }
        }()
        for {
            m[1] = struct{}{}
        }
    }()

    go func() {
        defer func() {
            if err := recover(); err != nil {
                fmt.Println("goroutine 2", err)
            }
        }()
        for {
            m[1] = struct{}{}
        }
    }()

    select {}
}

這裡啟動兩個 goroutine 來併發的對 map 進行寫操作,並且每個 goroutine 中都使用 defer + recover 來保證能夠正常處理 panic 發生。

最後使用 select {} 阻塞主 goroutine 防止程式退出。

執行示例程式碼,得到輸出如下:

$ go run main.go
fatal error: concurrent map writes

goroutine 3 [running]:
main.f.func1()
        /go/blog-go-example/error/defer-panic-recover/recover/main.go:156 +0x4c
created by main.f in goroutine 1
        /go/blog-go-example/error/defer-panic-recover/recover/main.go:149 +0x50

goroutine 1 [select (no cases)]:
main.f()
        /go/blog-go-example/error/defer-panic-recover/recover/main.go:171 +0x84
main.main()
        /go/blog-go-example/error/defer-panic-recover/recover/main.go:204 +0x1c

goroutine 4 [runnable]:
main.f.func2()
        /go/blog-go-example/error/defer-panic-recover/recover/main.go:167 +0x4c
created by main.f in goroutine 1
        /go/blog-go-example/error/defer-panic-recover/recover/main.go:160 +0x80
exit status 2

然而程式還是輸出 panic 資訊 fatal error: concurrent map writes 並退出了。

但是根據輸出資訊,我們無法知道具體原因。

Go 1.19 Release Notes 中有提到,從 Go 1.19 版本開始程式遇到不可恢復的致命錯誤(例如併發寫入 map,或解鎖未鎖定的互斥鎖)只會列印一個簡化的堆疊資訊,不包含執行時後設資料。不過這可以透過將環境變數 GOTRACEBACK 被設定為 systemcrash 來解決。

所以我們可以使用如下兩種方式來輸出更詳細的堆疊資訊:

$ GOTRACEBACK=system go run main.go
$ GOTRACEBACK=crash go run main.go

再次執行示例程式碼,得到輸出如下:

$  GOTRACEBACK=system go run main.go
fatal error: concurrent map writes

goroutine 4 gp=0x14000003180 m=3 mp=0x14000057008 [running]:
runtime.fatal({0x104904795?, 0x0?})
        /go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/runtime/panic.go:1088 +0x38 fp=0x14000051750 sp=0x14000051720 pc=0x104898a28
runtime.mapassign_fast64(0x104938ee0, 0x1400007a0c0, 0x1)
        /go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/runtime/map_fast64.go:122 +0x40 fp=0x14000051790 sp=0x14000051750 pc=0x1048cb5d0
main.f.func1()
        /go/blog-go-example/error/defer-panic-recover/recover/main.go:156 +0x4c fp=0x140000517d0 sp=0x14000051790 pc=0x1049017bc
runtime.goexit({})
        /go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/runtime/asm_arm64.s:1223 +0x4 fp=0x140000517d0 sp=0x140000517d0 pc=0x1048d4694
created by main.f in goroutine 1
        /go/blog-go-example/error/defer-panic-recover/recover/main.go:149 +0x50
...
exit status 2

這裡省略了大部分堆疊輸出,只保留了重要部分。根據堆疊資訊可以發現在 runtime/map_fast64.go:122 處發生了 panic

相關原始碼內容如下:

func mapassign_fast64(t *maptype, h *hmap, key uint64) unsafe.Pointer {
    if h == nil {
        panic(plainError("assignment to entry in nil map"))
    }
    if raceenabled {
        callerpc := getcallerpc()
        racewritepc(unsafe.Pointer(h), callerpc, abi.FuncPCABIInternal(mapassign_fast64))
    }
    if h.flags&hashWriting != 0 {
        fatal("concurrent map writes") // 第 122 行
    }
    ...
    return elem
}

顯然是第 122 行程式碼 fatal("concurrent map writes") 觸發了 panic,並且其引數內容 concurrent map writes 也正是輸出結果。

fatal 函式原始碼如下:

// fatal triggers a fatal error that dumps a stack trace and exits.
//
// fatal is equivalent to throw, but is used when user code is expected to be
// at fault for the failure, such as racing map writes.
//
// fatal does not include runtime frames, system goroutines, or frame metadata
// (fp, sp, pc) in the stack trace unless GOTRACEBACK=system or higher.
//
//go:nosplit
func fatal(s string) {
    // Everything fatal does should be recursively nosplit so it
    // can be called even when it's unsafe to grow the stack.
    systemstack(func() {
        print("fatal error: ")
        printindented(s) // logically printpanicval(s), but avoids convTstring write barrier
        print("\n")
    })

    fatalthrow(throwTypeUser)
}

fatal 內部呼叫了 fatalthrow 來觸發 panic。看來由 fatalthrow 所觸發的 panic 無法被 recover 捕獲。

我們開發時要切記:併發讀寫 map 觸發 panic,無法被 recover 捕獲。

併發操作 map 一定要小心,這是一個比較危險的行為,在 Web 開發中,如果在某個介面 handler 方法中觸發了 panic,整個 http Server 會直接掛掉。

涉及併發操作 map,我們應該使用 sync.Map 來代替:

func f() {
    m := sync.Map{}

    go func() {
        defer func() {
            if err := recover(); err != nil {
                fmt.Println("goroutine 1", err)
            }
        }()
        for {
            m.Store(1, struct{}{})
        }
    }()

    go func() {
        defer func() {
            if err := recover(); err != nil {
                fmt.Println("goroutine 2", err)
            }
        }()
        for {
            m.Store(1, struct{}{})
        }
    }()

    select {}
}

這個示例就不會 panic 了。

總結

本文對錯誤處理三劍客 deferpanicrecover 進行了講解梳理,雖然這三者並不是 error,但它們與錯誤處理息息相關。

defer 可以推遲一個函式或方法的呼叫,通常用於簡化執行各種清理操作的函式。

panic 是一個內建函式,它會停止程式的正常控制流並輸出 panic 相關資訊。相比於 errorpanic 更加暴力,謹慎使用。

recover 用來從 panic 所導致的程式崩潰中恢復執行,並且要與 defer 一起使用。

本文示例原始碼我都放在了 GitHub 中,歡迎點選檢視。

希望此文能對你有所啟發。

聯絡我

  • 公眾號:Go程式設計世界
  • 微信:jianghushinian
  • 郵箱:jianghushinian007@outlook.com
  • 部落格:https://jianghushinian.cn

相關文章