Go 部落格

延遲、驚慌和復原

Andrew Gerrand
2010 年 8 月 4 日

Go 具有一般的控制流程機制:if、for、switch、goto。另外也有 go 敘述來執行不同 goroutine 的程式碼。在此我想說明一些較不常見的機制,包括:defer、panic 和 recover。

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
}

這個指令有效,但有一個錯誤。如果呼叫 os.Create 失敗,函式會回傳,但不會關閉原始檔。只要在第二個回傳敘述前呼叫 src.Close,就可以輕鬆修正這個問題,但如果函式較複雜,這個問題可能就不容易發現和解決。透過加入 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 敘述讓我們在開啟檔案之後馬上想到要關閉檔案,並保證我們能夠在函式中的回傳敘述次數範圍內關閉檔案。

defer 敘述的行為很直接且可預測,有三個簡單的規則

  1. 在對 defer 敘述進行評估時,便會評估遞延函式的參數。

在此範例中,表達式「i」會在 Println 呼叫延後後才評估。延後呼叫會在功能傳回後列印「0」。

func a() {
    i := 0
    defer fmt.Println(i)
    i++
    return
}
  1. 延後功能呼叫會在包圍功能傳回後,以「後進先出」順序執行。

此功能會列印「3210」

func b() {
    for i := 0; i < 4; i++ {
        defer fmt.Print(i)
    }
}
  1. 延後功能可以讀取並指派到傳回功能的已命名傳回值。

在此範例中,延後功能會在包圍功能傳回遞增傳回值 i。因此,此功能會傳回 2

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

這很容易修改功能的錯誤傳回值;我們很快就會看到範例。

Panic 是內建功能,可以停止控制的一般流動並開始發散。當 F 功能呼叫 panic 時,F 的執行會停止,F 中的所有延後功能會正常執行,然後 F 傳回其呼叫端。對於呼叫端來說,F 的行為彷彿呼叫 panic。程序會一直沿堆疊持續進行,直到當前 goroutine 中的所有功能已傳回,此時程式會發生故障。可以透過直接呼叫 panic 來發動 Panics。Panic 也可以由執行時期錯誤所造成,例如超出範圍的陣列存取。

Recover 是內建功能,可以重新控制造成恐慌的 goroutine。Recover 僅能在延後功能的內部使用。在正常執行下,呼叫 recover 會傳回 nil,而且沒有其他作用。如果目前的 goroutine 造成恐慌,呼叫 recover 會擷取傳遞至 panic 的值,並恢復正常的執行。

以下是範例程式,用來示範 panic and defer 的運作機制

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 就造成恐慌,否則會以參數 i+1 自行呼叫。函數 f 延後一個呼叫 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.

如果我們從 f 中移除延後函數,就會無法還原 panic,而且會抵達 goroutine 呼叫堆疊的頂端,終止程式。下列改良後的程式會輸出

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
panic: 4

panic PC=0x2a9cd8
[stack trace omitted]

關於 panicrecover 的實際範例,請瀏覽 Go 標準函式庫中的 json 套件。它使用一組遞迴函數來編碼介面。如果在穿越值時發生錯誤,會呼叫 panic 以將堆疊一路展開到最上層函數呼叫,並從 panic 還原以及傳回適當的錯誤值(請參閱 encode.go 中編碼狀態類型中的「錯誤」與「封送」方法)。

Go 函式庫傳統上是即使套件在內部使用 panic,其外部 API 還是會提供明確的錯誤傳回值。

除了先前給出的 file.Close 範例之外,defer 的其他用途包括釋放 mutex

mu.Lock()
defer mu.Unlock()

印出頁尾

printHeader()
defer printFooter()

以及更多。

總之,defer 敘述(有或沒有 panic 和 recover)提供控制流程一個異常且強大的機制。可用於模擬其他程式語言中由特殊用途結構所實作的許多功能。試用看看。

下一篇文章:Go 榮獲 2010 年 Bossie 獎
上一篇文章:透過溝通共享記憶體
網誌索引