深刻理解 Go panic and recover

做爲一個 gophper,我相信你對於 panicrecover 確定不陌生,可是你有沒有想過。當咱們執行了這兩條語句以後。底層到底發生了什麼事呢?前幾天和同事恰好聊到相關的話題,發現其實你們對這塊理解仍是比較模糊的。但願這篇文章可以從更深刻的角度告訴你爲何,它到底作了什麼事?php

原文地址:深刻理解 Go panic and recovergit

思考

1、爲何會停止運行

func main() {
    panic("EDDYCJY.")
}

輸出結果:github

$ go run main.go
panic: EDDYCJY.

goroutine 1 [running]:
main.main()
    /Users/eddycjy/go/src/github.com/EDDYCJY/awesomeProject/main.go:4 +0x39
exit status 2

請思考一下,爲何執行 panic 後會致使應用程序運行停止?(而不是單單說執行了 panic 因此就結束了這麼含糊)golang

2、爲何不會停止運行

func main() {
    defer func() {
        if err := recover(); err != nil {
            log.Printf("recover: %v", err)
        }
    }()

    panic("EDDYCJY.")
}

輸出結果:數據結構

$ go run main.go 
2019/05/11 23:39:47 recover: EDDYCJY.

請思考一下,爲何加上 defer + recover 組合就能夠保護應用程序?函數

3、不設置 defer 行不

上面問題二是 defer + recover 組合,那我去掉 defer 是否是也能夠呢?以下:學習

func main() {
    if err := recover(); err != nil {
        log.Printf("recover: %v", err)
    }

    panic("EDDYCJY.")
}

輸出結果:ui

$ go run main.go
panic: EDDYCJY.

goroutine 1 [running]:
main.main()
    /Users/eddycjy/go/src/github.com/EDDYCJY/awesomeProject/main.go:10 +0xa1
exit status 2

居然不行,啊呀畢竟入門教程都寫的 defer + recover 組合 「萬能」 捕獲。可是爲何呢。去掉 defer 後爲何就沒法捕獲了?atom

請思考一下,爲何須要設置 deferrecover 才能起做用?spa

同時你還須要仔細想一想,咱們設置 defer + recover 組合後就能無憂無慮了嗎,各類 「亂」 寫了嗎?

4、爲何起個 goroutine 就不行

func main() {
    go func() {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("recover: %v", err)
            }
        }()
    }()

    panic("EDDYCJY.")
}

輸出結果:

$ go run main.go 
panic: EDDYCJY.

goroutine 1 [running]:
main.main()
    /Users/eddycjy/go/src/github.com/EDDYCJY/awesomeProject/main.go:14 +0x51
exit status 2

請思考一下,爲何新起了一個 Goroutine 就沒法捕獲到異常了?到底發生了什麼事...

源碼

接下來咱們將帶着上述 4+1 個小思考題,開始對源碼的剖析和分析,嘗試從閱讀源碼中找到思考題的答案和更多爲何

數據結構

type _panic struct {
    argp      unsafe.Pointer
    arg       interface{} 
    link      *_panic 
    recovered bool
    aborted   bool 
}

panic 中是使用 _panic 做爲其基礎單元的,每執行一次 panic 語句,都會建立一個 _panic。它包含了一些基礎的字段用於存儲當前的 panic 調用狀況,涉及的字段以下:

  • argp:指向 defer 延遲調用的參數的指針
  • arg:panic 的緣由,也就是調用 panic 時傳入的參數
  • link:指向上一個調用的 _panic
  • recovered:panic 是否已經被處理,也就是是否被 recover
  • aborted:panic 是否被停止

另外經過查看 link 字段,可得知其是一個鏈表的數據結構,以下圖:

image

恐慌 panic

func main() {
    panic("EDDYCJY.")
}

輸出結果:

$ go run main.go
panic: EDDYCJY.

goroutine 1 [running]:
main.main()
    /Users/eddycjy/go/src/github.com/EDDYCJY/awesomeProject/main.go:4 +0x39
exit status 2

咱們去反查一下 panic 處理具體邏輯的地方在哪,以下:

$ go tool compile -S main.go
"".main STEXT size=66 args=0x0 locals=0x18
    0x0000 00000 (main.go:23)    TEXT    "".main(SB), ABIInternal, $24-0
    0x0000 00000 (main.go:23)    MOVQ    (TLS), CX
    0x0009 00009 (main.go:23)    CMPQ    SP, 16(CX)
    ...
    0x002f 00047 (main.go:24)    PCDATA    $2, $0
    0x002f 00047 (main.go:24)    MOVQ    AX, 8(SP)
    0x0034 00052 (main.go:24)    CALL    runtime.gopanic(SB)

顯然彙編代碼直指內部實現是 runtime.gopanic,咱們一塊兒來看看這個方法作了什麼事,以下(省略了部分):

func gopanic(e interface{}) {
    gp := getg()
    ...
    var p _panic
    p.arg = e
    p.link = gp._panic
    gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
    
    for {
        d := gp._defer
        if d == nil {
            break
        }

        // defer...
        ...
        d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

        p.argp = unsafe.Pointer(getargp(0))
        reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz))
        p.argp = nil

        // recover...
        if p.recovered {
            ...
            mcall(recovery)
            throw("recovery failed") // mcall should not return
        }
    }

    preprintpanics(gp._panic)

    fatalpanic(gp._panic) // should not return
    *(*int)(nil) = 0      // not reached
}
  • 獲取指向當前 Goroutine 的指針
  • 初始化一個 panic 的基本單位 _panic 用做後續的操做
  • 獲取當前 Goroutine 上掛載的 _defer(數據結構也是鏈表)
  • 若當前存在 defer 調用,則調用 reflectcall 方法去執行先前 defer 中延遲執行的代碼,若在執行過程當中須要運行 recover 將會調用 gorecover 方法
  • 結束前,使用 preprintpanics 方法打印出所涉及的 panic 消息
  • 最後調用 fatalpanic 停止應用程序,實際是執行 exit(2) 進行最終退出行爲的

經過對上述代碼的執行分析,可得知 panic 方法實際上就是處理當前 Goroutine(g) 上所掛載的 ._panic 鏈表(因此沒法對其餘 Goroutine 的異常事件響應),而後對其所屬的 defer 鏈表和 recover 進行檢測並處理,最後調用退出命令停止應用程序

沒法恢復的恐慌 fatalpanic

func fatalpanic(msgs *_panic) {
    pc := getcallerpc()
    sp := getcallersp()
    gp := getg()
    var docrash bool

    systemstack(func() {
        if startpanic_m() && msgs != nil {
            ...
            printpanics(msgs)
        }

        docrash = dopanic_m(gp, pc, sp)
    })

    systemstack(func() {
        exit(2)
    })

    *(*int)(nil) = 0
}

咱們看到在異常處理的最後會執行該方法,彷佛它承擔了全部收尾工做。實際呢,它是在最後對程序執行 exit 指令來達到停止運行的做用,但在結束前它會經過 printpanics 遞歸輸出全部的異常消息及參數。代碼以下:

func printpanics(p *_panic) {
    if p.link != nil {
        printpanics(p.link)
        print("\t")
    }
    print("panic: ")
    printany(p.arg)
    if p.recovered {
        print(" [recovered]")
    }
    print("\n")
}

因此不要覺得全部的異常都可以被 recover 到,實際上像 fatal errorruntime.throw 都是沒法被 recover 到的,甚至是 oom 也是直接停止程序的,也有反手就給你來個 exit(2) 教作人。所以在寫代碼時你應該要相對注意些,「恐慌」 是存在沒法恢復的場景的

恢復 recover

func main() {
    defer func() {
        if err := recover(); err != nil {
            log.Printf("recover: %v", err)
        }
    }()

    panic("EDDYCJY.")
}

輸出結果:

$ go run main.go 
2019/05/11 23:39:47 recover: EDDYCJY.

和預期一致,成功捕獲到了異常。可是 recover 是怎麼恢復 panic 的呢?再看看彙編代碼,以下:

$ go tool compile -S main.go
"".main STEXT size=110 args=0x0 locals=0x18
    0x0000 00000 (main.go:5)    TEXT    "".main(SB), ABIInternal, $24-0
    ...
    0x0024 00036 (main.go:6)    LEAQ    "".main.func1·f(SB), AX
    0x002b 00043 (main.go:6)    PCDATA    $2, $0
    0x002b 00043 (main.go:6)    MOVQ    AX, 8(SP)
    0x0030 00048 (main.go:6)    CALL    runtime.deferproc(SB)
    ...
    0x0050 00080 (main.go:12)    CALL    runtime.gopanic(SB)
    0x0055 00085 (main.go:12)    UNDEF
    0x0057 00087 (main.go:6)    XCHGL    AX, AX
    0x0058 00088 (main.go:6)    CALL    runtime.deferreturn(SB)
    ...
    0x0022 00034 (main.go:7)    MOVQ    AX, (SP)
    0x0026 00038 (main.go:7)    CALL    runtime.gorecover(SB)
    0x002b 00043 (main.go:7)    PCDATA    $2, $1
    0x002b 00043 (main.go:7)    MOVQ    16(SP), AX
    0x0030 00048 (main.go:7)    MOVQ    8(SP), CX
    ...
    0x0056 00086 (main.go:8)    LEAQ    go.string."recover: %v"(SB), AX
    ...
    0x0086 00134 (main.go:8)    CALL    log.Printf(SB)
    ...

經過分析底層調用,可得知主要是以下幾個方法:

  • runtime.deferproc
  • runtime.gopanic
  • runtime.deferreturn
  • runtime.gorecover

在上小節中,咱們講述了簡單的流程,gopanic 方法會調用當前 Goroutine 下的 defer 鏈表,若 reflectcall 執行中遇到 recover 就會調用 gorecover 進行處理,該方法代碼以下:

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

這代碼,看上去挺簡單的,核心就是修改 recovered 字段。該字段是用於標識當前 panic 是否已經被 recover 處理。可是這和咱們想象的並不同啊,程序是怎麼從 panic 流轉回去的呢?是否是在覈心方法裏處理了呢?咱們再看看 gopanic 的代碼,以下:

func gopanic(e interface{}) {
    ...
    for {
        // defer...
        ...
        pc := d.pc
        sp := unsafe.Pointer(d.sp) // must be pointer so it gets adjusted during stack copy
        freedefer(d)
        
        // recover...
        if p.recovered {
            atomic.Xadd(&runningPanicDefers, -1)

            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") 
        }
    }
    ...
}

咱們回到 gopanic 方法中再仔細看看,發現其實是包含對 recover 流轉的處理代碼的。恢復流程以下:

  • 判斷當前 _panic 中的 recover 是否已標註爲處理
  • _panic 鏈表中刪除已標註停止的 panic 事件,也就是刪除已經被恢復的 panic 事件
  • 將相關須要恢復的棧幀信息傳遞給 recovery 方法的 gp 參數(每一個棧幀對應着一個未運行完的函數。棧幀中保存了該函數的返回地址和局部變量)
  • 執行 recovery 進行恢復動做

從流程來看,最核心的是 recovery 方法。它承擔了異常流轉控制的職責。代碼以下:

func recovery(gp *g) {
    sp := gp.sigcode0
    pc := gp.sigcode1

    if sp != 0 && (sp < gp.stack.lo || gp.stack.hi < sp) {
        print("recover: ", hex(sp), " not in [", hex(gp.stack.lo), ", ", hex(gp.stack.hi), "]\n")
        throw("bad recovery")
    }

    gp.sched.sp = sp
    gp.sched.pc = pc
    gp.sched.lr = 0
    gp.sched.ret = 1
    gogo(&gp.sched)
}

粗略一看,彷佛就是很簡單的設置了一些值?但實際上設置的是編譯器中僞寄存器的值,經常被用於維護上下文等。在這裏咱們須要結合 gopanic 方法一同觀察 recovery 方法。它所使用的棧指針 sp 和程序計數器 pc 是由當前 defer 在調用流程中的 deferproc 傳遞下來的,所以實際上最後是經過 gogo 方法跳回了 deferproc 方法。另外咱們注意到:

gp.sched.ret = 1

在底層中程序將 gp.sched.ret 設置爲了 1,也就是沒有實際調用 deferproc 方法,直接修改了其返回值。意味着默認它已經處理完成。直接轉移到 deferproc 方法的下一條指令去。至此爲止,異常狀態的流轉控制就已經結束了。接下來就是繼續走 defer 的流程了

爲了驗證這個想法,咱們能夠看一下核心的跳轉方法 gogo ,代碼以下:

// void gogo(Gobuf*)
// restore state from Gobuf; longjmp
TEXT runtime·gogo(SB),NOSPLIT,$8-4
    MOVW    buf+0(FP), R1
    MOVW    gobuf_g(R1), R0
    BL    setg<>(SB)

    MOVW    gobuf_sp(R1), R13    // restore SP==R13
    MOVW    gobuf_lr(R1), LR
    MOVW    gobuf_ret(R1), R0
    MOVW    gobuf_ctxt(R1), R7
    MOVW    $0, R11
    MOVW    R11, gobuf_sp(R1)    // clear to help garbage collector
    MOVW    R11, gobuf_ret(R1)
    MOVW    R11, gobuf_lr(R1)
    MOVW    R11, gobuf_ctxt(R1)
    MOVW    gobuf_pc(R1), R11
    CMP    R11, R11 // set condition codes for == test, needed by stack split
    B    (R11)

經過查看代碼可得知其主要做用是從 Gobuf 恢復狀態。簡單來說就是將寄存器的值修改成對應 Goroutine(g) 的值,而在文中講了不少次的 Gobuf,以下:

type gobuf struct {
    sp   uintptr
    pc   uintptr
    g    guintptr
    ctxt unsafe.Pointer
    ret  sys.Uintreg
    lr   uintptr
    bp   uintptr
}

講道理,其實它存儲的就是 Goroutine 切換上下文時所須要的一些東西

拓展

const(
    OPANIC       // panic(Left)
    ORECOVER     // recover()
    ...
)
...
func walkexpr(n *Node, init *Nodes) *Node {
    ...
    switch n.Op {
    default:
        Dump("walk", n)
        Fatalf("walkexpr: switch 1 unknown op %+S", n)

    case ONONAME, OINDREGSP, OEMPTY, OGETG:
    case OTYPE, ONAME, OLITERAL:
        ...
    case OPANIC:
        n = mkcall("gopanic", nil, init, n.Left)

    case ORECOVER:
        n = mkcall("gorecover", n.Type, init, nod(OADDR, nodfp, nil))
    ...
}

實際上在調用 panicrecover 關鍵字時,是在編譯階段先轉換爲相應的 OPCODE 後,再由編譯器轉換爲對應的運行時方法。並非你所想像那樣一步到位,有興趣的小夥伴能夠研究一下

總結

本文主要針對 panicrecover 關鍵字進行了深刻源碼的剖析,而開頭的 4+1 個思考題,就是但願您可以帶着疑問去學習,達到事半功倍的功效

另外本文和 defer 有必定的關聯性,所以須要有必定的基礎知識。若剛剛看的時候這部分不理解,學習後能夠再讀一遍加深印象

在最後,如今的你能夠回答這幾個思考題了嗎?說出來了纔是真的懂 :)

相關文章
相關標籤/搜索