前言
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,并且存在以下限制:
- recover只能存在defer函数中使用且defer函数必须放在panic之前定义。
- recover必须在最顶层的函数使用。
- 多个defer中存在recover只会被第一个调用recover捕捉。
- 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时,会按顺序执行以下流程:
-
创建新的_panic置于当前goroutine的_panic链表首位。
-
在goroutine的链表中取出_defer结构体并执行。
-
当执行的延迟函数中存在recover,则把_panic的recovered标记为true,表示被recover捕捉。
- 当程序被recover捕捉后,当前goroutine的_panic链表指向下一个_panic。
- 当_panic标记终止时取出程序计数器
pc和栈指针sp并调用recovery函数进行恢复程序。
-
当遍历所有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哥:“我错了......”
本故事纯属虚构,没有雷同