最近工作中审查代码的时候发现一段代码,类似于如下这样,将 recover 放到一个子协程里面,期望去捕获主协程的程序异常
图片
看到此处,是否会想这段代码在项目中是想当然写出来的吧,然而平日中,大多问题是出现在认知偏差上,那么本次,我们就来消除一下这个认知偏差
关于 Go 语言中显示的使用 panic 的地方不多,一般 panic ,基本上会出现在咱们程序出现异常退出的时候
例如访问了空指针里面的值,则会 panic 报错无效的内存地址,又例如访问量数组中不存在的数组所索引,或者切片索引,那么会报错 panic 数组越界等等
可是碰到这些 panic 的时候,实际上我们并不期望当前的服务直接挂掉,而是期望这个异常能够被识别,且不影响程序其他部分的模块运行
正常捕获异常
在 Go 中可以将 defer 和 recover 进行搭配使用,可以捕获和处理大部分的异常情况,例如可以这样
图片
这里可以看到,recover 捕获异常和发生异常的部分是在同一个协程中,实验证明是可以正常捕获并且处理异常
并没有捕获到异常
- 直接不做显示的 recover,自然 panic 程序崩溃会如期而至,此处我们显示的使用 panic 函数来制造恐慌
func main() {
log.SetFlags(log.Lshortfile)
panic("panic coming...")
}
图片
- 不使用 defer 来进行处理
func main() {
log.SetFlags(log.Lshortfile)
if err := recover(); err != nil {
log.Println("recover panic : ", err)
}
panic("panic coming...")
}
图片
自然 recover 函数是在 panic 调用之前就已经执行,此时是还没有异常需要捕获和恢复的,待程序运行到 panic 处的时候,实际上并没有没有处理程序崩溃的异常
结果,仍然是程序崩溃
- 当然,还有文章开头提到的出现 panic 的位置和捕获和处理程序崩溃异常的位置不在同一个协程,自然也是没法捕获到的,这一点需要注意,其他的语言可能不是这样,但是 Go 中是这样的
panic 基本原理
看了上述现象,实际上还是对知识点理解得不够,使用的时候想当然了,就像使用 defer 一样,如果对他不够了解的话,使用的时候,确实会出现一些奇奇怪怪的现象,对于 defer 的使用可以查看文末的文章地址
- panic 函数和 recover 函数,Go 源码builtin\builtin.go中可以看到注释
图片
注释中有说关于 panic 和 recover 的使用是作用于当前协程的,因此我们使用的时候,如果跨协程教程使用,自然不会达到我们期望的效果
- 继续查看关于 panic 的源码,实际上是一个结构,放到 defer 结构里面的一个指针,源码位置:runtime\runtime2.go
图片
_panic 的结构如下:
type _panic struct {
argp unsafe.Pointer
arg interface{}
link *_panic
pc uintptr
sp unsafe.Pointer
recovered bool
aborted bool
goexit bool
}
上述两个结构表达的意思是,程序中出现 panic 的时候,实际上都会创建一个 _panic 结构,这个 _panic 结构里面存储了当前程序崩溃的一些必要信息,如下:
- argp
是一个 unsafe.Pointer 类型的成员,指向 defer 调用参数的指针
- arg
出现 panic 的原因,如果我们显示调用 panic,那么就是我们填入 panic 函数中的参数,例如上述的 panic coming ...
- link
是一个指针,指向上一个,最近的一个 _panic 结构的地址,实际上此处就可以看到这个指针对应的是一个链表,一个又多个 _panic 结构组成的链表
图片
- recovered
panic 是否已经处理完毕,即当前的这个 panic 是否是已经被 recover 了
- aborted
表示当前的 panic 是否被中止
- 对于 pc 和 sp 自然就是我们熟知的 pc 通用寄存器,在汇编中是指向当前运行指令的下一条指令,sp 则是栈指针 stack pointer,用于入栈和出栈的
我们知道运行函数的时候需要入栈,运行完毕之后需要出栈
源码中的 runtime.gopanic
那么我们继续来阅读源码,上述看到 sp 和 pc ,那么我们就简单写一个 panic 的代码来看看汇编到底是怎么执行的,不用担心看不懂,我们只需要看关键词就行
还是上面的程序
图片
程序运行的时候可以执行 go tool compile -S main.go
可以看到汇编代码,可能其他的看不懂,但是我们可以看到如下关键词
图片
- log.(*Logger).SetFlags(SB) 即是执行到我们调用 log 去设置参数
- 程序走到 panic 函数的时候,实际上是执行了 runtime.gopanic 函数,我们一起看看源码
图片
代码中可以看到 p.recovered 逻辑下的关于 recover 的逻辑被删除掉了,在文章的后面会继续说到,当前我们先关注 panic 的事项
runtime.gopanic 程序的逻辑大体是这样的
- 获取当前 协程 的指针
- 初始化一个 _panic 结构 p,并将当前协程上对应的数据赋值给到 p 上,且将 当前协程 _panic 挂到 link 上
- 进入循环后,拿到当前协程的 _defer 数据
- 查看 _defer 指针数据 中是否有 defer 调用,如果有则执行
- 处理完基本逻辑之后,打印 panic 信息,例如我们 demo 中的 panic coming ... 信息
- 最终退出程序
Xdm 可以看上图,自己捋一捋逻辑就清晰了
接着,我们来看
fatalpanic
图片
通过 runtime.gopanic 我们可以看到 fatalpanic 函数基本上就是做一个收尾工作了,如果上述程序处理完毕之后, fatalpanic 校验到 panic 是需要 recover 的,那么就打印 [recovered]
打印的这个信息是由 上图中 printpanics 完成的
图片
这下知道 panic 是如何去执行的了,那么对于现在来研究 recover 是如何落实的
recover
还是同一个例子,咱们将 defer 部分的代码注打开,来继续看看效果
func main() {
log.SetFlags(log.Lshortfile)
defer func() {
if err := recover(); err != nil {
log.Println("recover panic : ", err)
}
}()
panic("panic coming...")
}
自然效果是我们期望的,捕获到了异常,且处理了
图片
继续打印汇编来查看一下关键词,是否有我们期望的函数出现
图片
图片
此处我们可以看到,实际 Go 中调用了多个函数
- runtime.gorecover
- main.main.opendefer
- log.(*Logger).SetFlags
- runtime.gopanic
- runtime.deferreturn
自然明眼人都看的出现,关键的函数实现自然是 runtime.gorecover ,那么我们来一探究竟
runtime.gorecover
图片
查看源码我们可以知道, runtime.gorecover 实际上就是根据当前协程的 _panic 结构数据来判断是否需要恢复,如果需要则将 p.recovered = true
自然在这里将当前协程的数据修改掉,正是为了后续执行 runtime.gopanic 的时候提供保障, runtime.gopanic 执行的时候就会去判断和处理这个 p.recovered
前文中提到的关于 runtime.gopanic 中 处理 p.recovered 的逻辑是这样的
图片
图片
- 如上可以看到 runtime.gorecover 去对 p.recovered 设置是否恢复
- runtime.gopanic 中校验 p.recovered 已处理,则执行 recovery 函数
- recovery 函数中去处理对应的寄存器的值去维护上下文
- 最后我们可以看到最终调用 gogo 函数跳回原来调用的位置
因此,当我们在同一个协程中出现了 panic,且在同一个协程中去使用 defer 来配合 recover 来进行捕获异常和处理异常,就可以得以实现,看到这里,有没有觉得还是蛮简单的,不就是去对一个 p.recovered 进行配合处理吗
自然,表面上是这样,其中对于寄存器的各种数据处理涉及的内容还是不少的,不过这不在我们今天聊的范畴中了
总结
至此,相信你已经知道了这些
- 为什么 panic 和 defer ,recover 配合使用的时候要在同一个协程中了吧
- 相信你还知道了 panic 和 recover 的处理流程