【Go進階—基礎特性】panic 和 recover

與昊發表於2022-04-15

panic 和 recover 也是常用的關鍵字,這兩個關鍵字與上一篇提到的 defer 聯絡很緊密。用一句話總結就是:呼叫 panic 後會立刻停止執行當前函式的剩餘程式碼,並在當前 Goroutine 中遞迴執行呼叫方的 defer;而 recover 可以中止 panic 造成的程式崩潰,不過它只能在 defer 中發揮作用。

panic

panic 是一個內建函式,接受一個任意型別的引數,引數將在程式崩潰時列印出來,如果被 recover 恢復的話,該引數也是 recover 的返回值。panic 可以由程式設計師顯式觸發,執行時遇到意料之外的錯誤如記憶體越界時也會觸發。

在上一篇中我們知道每個 Goroutine 都維護了一個 _defer 連結串列(非開放編碼情況下),執行過程中每遇到一個 defer 關鍵字都會建立一個 _defer 例項插入連結串列,函式退出時一次取出這些 _defer 例項並執行。panic 發生時,實際上是觸發了函式退出,也即把執行流程轉向了 _defer 連結串列。

panic 的執行過程中有幾點需要明確:

  • panic 會遞迴執行當前 Goroutine 中所有的 defer,處理完成後退出;
  • panic 不會處理其他 Goroutine 中的 defer;
  • panic 允許在 defer 中多次呼叫,程式會終止當前 defer 的執行,繼續之前的流程。

資料結構

panic 關鍵字在 Go 語言中是由資料結構 runtime._panic 表示的。每當我們呼叫 panic 都會建立一個如下所示的資料結構:

type _panic struct {
    argp      unsafe.Pointer
    arg       interface{}
    link      *_panic
    recovered bool
    aborted   bool
    goexit    bool
}
  • argp 是指向 defer 函式引數的指標;
  • arg 是呼叫 panic 時傳入的引數;
  • link 指向前一個_panic 結構;
  • recovered 表示當前 _panic 是否被 recover 恢復;
  • aborted 表示當前的 _panic 是否被終止;
  • goexit 表示當前 _panic 是否是由 runtime.Goexit 產生的。

_panic 連結串列與 _defer 連結串列一樣,都是儲存在 Goroutine 的資料結構中:

type g struct {
    // ...
    _panic    *_panic
    _defer    *_defer
    // ...
}

執行過程

編譯器會將關鍵字 panic 轉換成 runtime.gopanic 函式,我們來看一下它的核心程式碼:

func gopanic(e interface{}) {
    gp := getg()
    ...
    var p _panic       // 建立新的 _panic 結構
    p.arg = e          // 儲存 panic 的引數
    p.link = gp._panic 
    gp._panic = (*_panic)(noescape(unsafe.Pointer(&p))) // 這兩行是將新結構插入到當前 Goroutine 的 panic 連結串列頭部

    for {
        d := gp._defer // 開始遍歷 _defer 連結串列
        if d == nil {
            break
        }

        // 巢狀 panic 的情形
        if d.started {
            if d._panic != nil {
                d._panic.aborted = true // 標記之前 _defer 中的 _panic 為已終止
            }
            // 從連結串列中刪除本 defer
            d._panic = nil
            if !d.openDefer {
                d.fn = nil
                gp._defer = d.link
                freedefer(d)
                continue
            }
        }

        d.started = true // 標記 defer 已經開始執行

        d._panic = (*_panic)(noescape(unsafe.Pointer(&p))) // 標記觸發 defer 的 _panic

        reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz)) // 執行 defer 函式,省略對開放編碼 _defer 的額外處理

        d._panic = nil
        d.fn = nil
        gp._defer = d.link

        pc := d.pc
        sp := unsafe.Pointer(d.sp)
        freedefer(d)
        // 如果被 recover 恢復的話,處理下面的邏輯
        if p.recovered {
            // ...
            gp._panic = p.link
            for gp._panic != nil && gp._panic.aborted {
                gp._panic = gp._panic.link
            }
            if gp._panic == nil {
                gp.sig = 0
            }

            gp.sigcode0 = uintptr(sp)
            gp.sigcode1 = pc
            mcall(recovery)
            throw("recovery failed") // mcall should not return
        }
    }

    fatalpanic(gp._panic) // 終止整個程式
    *(*int)(nil) = 0
}

該函式的執行過程包含以下幾個步驟:

  1. 建立新的 runtime._panic 並新增到所在 Goroutine 的 _panic 連結串列的最前面;
  2. 判斷是否是巢狀 panic 的情形,進行相關標記和處理;
  3. 不斷從當前 Goroutine 的 _defer 連結串列中獲取 _defer 並呼叫 runtime.reflectcall 執行延遲呼叫函式;
  4. 呼叫 runtime.fatalpanic 中止整個程式。

recover

recover 也是一個內建函式,用於消除 panic 並使程式恢復正常。recover 的執行過程也有幾點需要明確:

  • recover 的返回值就是消除的 panic 的引數;
  • recover 必須直接位於 defer 函式內(不能出現在另一個巢狀函式中)才能生效;
  • recover 成功處理異常後,函式不會繼續處理 panic 之後的邏輯,會直接返回,對於匿名返回值將返回相應的零值。

執行過程

編譯器會將關鍵字 recover 轉換成 runtime.gorecover:

func gorecover(argp uintptr) interface{} {
    gp := getg()
    p := gp._panic
    if p != nil && !p.goexit && !p.recovered && argp == uintptr(p.argp) {
        p.recovered = true
        return p.arg
    }
    return nil
}

函式的實現很簡單,獲取當前 Goroutine 中的 _panic 例項,在符合條件的情況下將 _panic 例項的 recovered 狀態標記為 true,然後返回 panic 函式的引數。

我們來看一下 recover 的幾個生效條件:

  • p != nil:必須存在 panic;
  • !p.goexit:非 runtime.Goexit();
  • !p.recovered:還未被恢復;
  • argp == uintptr(p.argp):recover 必須在 defer 中直接呼叫。

首先,必須存在 panic,runtime.Goexit() 產生的 panic 無法被恢復,這些沒什麼好說的。假設函式包含多個 defer,前面的 defer 通過 recover 消除 panic 後,剩餘 defer 中的 recover 不能再次恢復。

有一點會讓人感到疑惑,recover 函式沒有引數,為什麼 gorecover 函式卻有引數?這正是為了限制 recover 必須在 defer 中被直接呼叫。gorecover 函式的引數為呼叫 recover 函式的引數地址,_panic 結構中儲存了當前 defer 函式的引數地址,如果二者一致,說明 recover 是在 defer 中被直接呼叫。示例如下:

func test() {
    defer func() { // func A
        func() { // func B
            // gorecover 的引數 argp 為 B 的引數地址,p.argp 為 A 的引數的指標
            // argp != p.argp,無法恢復
            if err := recover(); err != nil {
                fmt.Println(err)
            }
        }()
    }()
}

相關文章