捕捉不到的panic

167 阅读5分钟

前言

B哥:“大事不好啦,我要被扣绩效啦!快来帮忙啊!”

李晓得:“咋啦,今天这么慌张,我还是喜欢你平时桀骜不驯的样子。”

B哥:“线上有应用实例挂掉啦,快帮忙看看日志。”

经过一阵折腾...

李晓得:“有协程panic了,没有捕捉到。已经修复了,还好没有影响到正常业务。”

B哥:“呼,绩效可算保住了。”

与recover的搭配

通常情况下协程的panic也会到主程序的终止,下面的并不会输出"程序执行完毕":

func main() {
    go func() {
        panic("一个致命错误")
    }()
    time.Sleep(1 * time.Second)
    fmt.Println("程序执行完毕")
}
//输出:
//panic: 一个致命错误
//goroutine 6 [running]:
//main.main.func1()

而在go语言中,panic的捕捉只能通过recover,并且存在以下限制:

  1. recover只能存在defer函数中使用且defer函数必须放在panic之前定义。
  2. recover必须在最顶层的函数使用。
  3. 多个defer中存在recover只会被第一个调用recover捕捉。
  4. recover处理完异常后并不会跳转回panic出错时的代码继续运行,而是走defer流程运行代码。

该定义在go的官方包runtime/panic.go中可以找到相关说明以及执行逻辑:

func gorecover(argp uintptr) any {
    // Must be in a function running as part of a deferred call during the panic.
    // Must be called from the topmost function of the call
    // (the function used in the defer statement).
    // p.argp is the argument pointer of that topmost deferred function call.
    // Compare against argp reported by caller.
    // If they match, the caller is the one who can recover.
    gp := getg()
    p := gp._panic
    if p != nil && !p.goexit && !p.recovered && argp == uintptr(p.argp) {
        p.recovered = true
        return p.arg
    }
    return nil
}

因此,一种常见的panic捕捉函数封装如下:

package main

import (
    "fmt"
    "time"
)

func main() {
    goSafe(func() {
        //不小心引发panic
        panic("一个致命错误")
        fmt.Println("这句话在panic后,即使被recover了也不会输出")
    })
    time.Sleep(1 * time.Second)
    fmt.Println("执行完毕")
}

func goSafe(fn func()) {
    go func() {
        defer func() {
            if re := recover(); re != nil {
                //协程异常捕捉及重试
                fmt.Println("捕捉到异常啦:", re)
            }
        }()
        fn()
    }()
}

//输出:
//捕捉到异常啦: 一个致命错误
//执行完毕

B哥:“我懂了!这不就是try-catch吗?”

是的,利用go语言的panic和recover确实可以很简单的实现类似try-catch用法:

func main() {
    Try(func() {
        panic("一个致命错误")
    }, func(a any) {
        fmt.Println("捕捉到异常啦:", a)
    })
    time.Sleep(1 * time.Second)
    fmt.Println("执行完毕")
}

func Try(tryFn func(), catchFn func(any)) {
    defer func() {
        if re := recover(); re != nil {
            catchFn(re)
        }
    }()
    tryFn()
}

但是,请注意,这种用法在线上请慎重考虑!因为在其他语言中try-catch的作用是在try代码块中,而go的panic的作用是在goroutine的调用栈,不应该用其替代error的写法:

//使用err返回业务错误,推荐
func getOrderById(id int) error {
    if id == 0 {
        return errors.New("查询订单信息失败")
    }
    return nil
}
func getOrder(id int) (err error) {
    err = getOrderById(id)
    if err != nil {
        return
    }
    return nil
}

//使用panic返回业务错误,不推荐!!!!!错误姿势!!!
func getOrderById2(id int) error {
    if id == 0 {
        panic("查询订单信息失败")
    }
    return nil
}
func getOrder2(id int) (err error) {
    Try(func() {
        getOrderById2(id)
    }, func(a any) {
        err = errors.New(fmt.Sprint(a))
    })
    return
}

在go语言的使用惯例中,panic的出现应该是系统程序中出现了不可修复性错误,其他场景应该使用error。

或许可以深入了解一下

在go语言中,关于panic和recover的代码都在runtime/panic.go中,panic和recover关键字在编译的时候会转换为runtime.gopanic()runtime.gorecover()。重点看下gopanic函数以及_painc结构:

//goroutine中的g结构,存放栈信息以及_panic链表和_defer链表等
type g struct {
    //略
    stack       stack   // offset known to runtime/cgo
    stackguard0 uintptr // offset known to liblink
    stackguard1 uintptr // offset known to liblink

    _panic    *_panic // innermost panic - offset known to liblink
    _defer    *_defer // innermost defer
    m         *m      // current m; offset known to arm liblink
}

type _panic struct {
    argp      unsafe.Pointer // 指向defer调用时参数的指针
    arg       any            // 调用panic时传入的参数
    link      *_panic        // 更早调用的_panic结构
    pc        uintptr        // where to return to in runtime if this panic is bypassed
    sp        unsafe.Pointer // where to return to in runtime if this panic is bypassed
    recovered bool           // 当前panic是否被recover恢复
    aborted   bool           // 当前panic是否被强行终止
    goexit    bool           // 是否退出协程
}

func gopanic(e any) {
    //获取g结构体
    gp := getg()
    //略
    //创建新的_panic链表
    var p _panic
    p.arg = e
    //新建的_panic置于goroutine的_panic链表最前面
    p.link = gp._panic
    gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
    runningPanicDefers.Add(1)

    for {
        d := gp._defer
        if d == nil {
            break
        }
        //代码略
        //defer函数处理
        if d.openDefer {
            done = runOpenDeferFrame(d)
            if done && !d._panic.recovered {
                addOneOpenDeferFrame(gp, 0, nil)
            }
        } else {
            p.argp = unsafe.Pointer(getargp())
            d.fn()
        }

        if p.recovered {
            //recover处理完毕,恢复调用
            gp._panic = p.link
            if gp._panic != nil && gp._panic.goexit && gp._panic.aborted {
                // A normal recover would bypass/abort the Goexit.  Instead,
                // we return to the processing loop of the Goexit.
                gp.sigcode0 = uintptr(gp._panic.sp)
                gp.sigcode1 = uintptr(gp._panic.pc)
                mcall(recovery)
                throw("bypassed recovery failed") // mcall should not return
            }
            runningPanicDefers.Add(-1)
            //代码略
        }
    }

    //终止整个程序
    fatalpanic(gp._panic) // should not return
    *(*int)(nil) = 0      // not reached
}

至此,可以很容易看出当程序执行遇到panic时,会按顺序执行以下流程:

  1. 创建新的_panic置于当前goroutine的_panic链表首位。

  2. 在goroutine的链表中取出_defer结构体并执行。

  3. 当执行的延迟函数中存在recover,则把_panic的recovered标记为true,表示被recover捕捉。

    1. 当程序被recover捕捉后,当前goroutine的_panic链表指向下一个_panic。
    2. 当_panic标记终止时取出程序计数器 pc 和栈指针 sp 并调用recovery函数进行恢复程序。
  4. 当遍历所有defer也没有遇到recover,则调用fatalpanic终止整个程序。

失控的panic

B哥:“我已经按照上面的gosafe()编写标准的协程捕获程序啦,还是出现panic了,咋回事啊?”

李晓得:“我知道你很急,但是你先别急。”

B哥:“.......”

在现实的情况中,一个go程序往往会依赖很多库进行开发,如果遇到关于协程使用的库,在调用的时候就往往需要注意协程的调用情况了。比如官方的sync包,当使用errgroup用作并发处理时,也会出现不可捕获panic:

package main

import (
    "errors"
    "fmt"
    "golang.org/x/sync/errgroup"
)

func main() {
    defer func() {
        if re := recover(); re != nil {
            fmt.Println("第三方库开启协程时没有做recover捕捉,在外部recover也无济于事")
        }
    }()
    eg := errgroup.Group{}
    eg.Go(func() error {
        var s1 = make([]int, 0)
        fmt.Println(s1[0])
        return nil
    })
    eg.Go(func() error {
        fmt.Println("并行任务")
        return nil
    })
    eg.Wait()
    fmt.Println("程序执行完毕")
}

//源码中defer并没有做recover捕捉,使用时非常考验新手程序员对逻辑代码的把控
func (g *Group) Go(f func() error) {
    if g.sem != nil {
        g.sem <- token{}
    }

    g.wg.Add(1)
    go func() {
        defer g.done()

        if err := f(); err != nil {
            g.errOnce.Do(func() {
                g.err = err
                if g.cancel != nil {
                    g.cancel()
                }
        })
        }
    }()
}

//输出:
//并行任务
//panic: runtime error: index out of range [0] with length 0

B哥:“很棒!我觉得我又行了!”

李晓得:“组长说还是要把你这个月的绩效扣一扣。”

B哥:“我错了......”

本故事纯属虚构,没有雷同