6.1 panic and recover
作为一个 gophper,我相信你对于 panic 和 recover 肯定不陌生,但是你有没有想过。当我们执行了这两条语句之后。底层到底发生了什么事呢?前几天和同事刚好聊到相关的话题,发现其实大家对这块理解还是比较模糊的。希望这篇文章能够从更深入的角度告诉你为什么,它到底做了什么事?
思考
一、为什么会中止运行
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 后会导致应用程序运行中止?(而不是单单说执行了 panic 所以就结束了这么含糊)
二、为什么不会中止运行
func main() {
defer func() {
if err := recover(); err != nil {
log.Printf("recover: %v", err)
}
}()
panic("EDDYCJY.")
}输出结果:
请思考一下,为什么加上 defer + recover 组合就可以保护应用程序?
三、不设置 defer 行不
上面问题二是 defer + recover 组合,那我去掉 defer 是不是也可以呢?如下:
输出结果:
竟然不行,啊呀毕竟入门教程都写的 defer + recover 组合 “万能” 捕获。但是为什么呢。去掉 defer 后为什么就无法捕获了?
请思考一下,为什么需要设置 defer 后 recover 才能起作用?
同时你还需要仔细想想,我们设置 defer + recover 组合后就能无忧无虑了吗,各种 “乱” 写了吗?
四、为什么起个 goroutine 就不行
输出结果:
请思考一下,为什么新起了一个 Goroutine 就无法捕获到异常了?到底发生了什么事...
源码
接下来我们将带着上述 4+1 个小思考题,开始对源码的剖析和分析,尝试从阅读源码中找到思考题的答案和更多为什么
数据结构
在 panic 中是使用 _panic 作为其基础单元的,每执行一次 panic 语句,都会创建一个 _panic。它包含了一些基础的字段用于存储当前的 panic 调用情况,涉及的字段如下:
argp:指向
defer延迟调用的参数的指针arg:
panic的原因,也就是调用panic时传入的参数link:指向上一个调用的
_panicrecovered:
panic是否已经被处理,也就是是否被recoveraborted:
panic是否被中止
另外通过查看 link 字段,可得知其是一个链表的数据结构,如下图:

恐慌 panic
输出结果:
我们去反查一下 panic 处理具体逻辑的地方在哪,如下:
显然汇编代码直指内部实现是 runtime.gopanic,我们一起来看看这个方法做了什么事,如下(省略了部分):
获取指向当前
Goroutine的指针初始化一个
panic的基本单位_panic用作后续的操作获取当前
Goroutine上挂载的_defer(数据结构也是链表)若当前存在
defer调用,则调用reflectcall方法去执行先前defer中延迟执行的代码,若在执行过程中需要运行recover将会调用gorecover方法结束前,使用
preprintpanics方法打印出所涉及的panic消息最后调用
fatalpanic中止应用程序,实际是执行exit(2)进行最终退出行为的
通过对上述代码的执行分析,可得知 panic 方法实际上就是处理当前 Goroutine(g) 上所挂载的 ._panic 链表(所以无法对其他 Goroutine 的异常事件响应),然后对其所属的 defer 链表和 recover 进行检测并处理,最后调用退出命令中止应用程序
无法恢复的恐慌 fatalpanic
我们看到在异常处理的最后会执行该方法,似乎它承担了所有收尾工作。实际呢,它是在最后对程序执行 exit 指令来达到中止运行的作用,但在结束前它会通过 printpanics 递归输出所有的异常消息及参数。代码如下:
所以不要以为所有的异常都能够被 recover 到,实际上像 fatal error 和 runtime.throw 都是无法被 recover 到的,甚至是 oom 也是直接中止程序的,也有反手就给你来个 exit(2) 教做人。因此在写代码时你应该要相对注意些,“恐慌” 是存在无法恢复的场景的
恢复 recover
输出结果:
和预期一致,成功捕获到了异常。但是 recover 是怎么恢复 panic 的呢?再看看汇编代码,如下:
通过分析底层调用,可得知主要是如下几个方法:
runtime.deferproc
runtime.gopanic
runtime.deferreturn
runtime.gorecover
在上小节中,我们讲述了简单的流程,gopanic 方法会调用当前 Goroutine 下的 defer 链表,若 reflectcall 执行中遇到 recover 就会调用 gorecover 进行处理,该方法代码如下:
这代码,看上去挺简单的,核心就是修改 recovered 字段。该字段是用于标识当前 panic 是否已经被 recover 处理。但是这和我们想象的并不一样啊,程序是怎么从 panic 流转回去的呢?是不是在核心方法里处理了呢?我们再看看 gopanic 的代码,如下:
我们回到 gopanic 方法中再仔细看看,发现实际上是包含对 recover 流转的处理代码的。恢复流程如下:
判断当前
_panic中的recover是否已标注为处理从
_panic链表中删除已标注中止的panic事件,也就是删除已经被恢复的panic事件将相关需要恢复的栈帧信息传递给
recovery方法的gp参数(每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数的返回地址和局部变量)执行
recovery进行恢复动作
从流程来看,最核心的是 recovery 方法。它承担了异常流转控制的职责。代码如下:
粗略一看,似乎就是很简单的设置了一些值?但实际上设置的是编译器中伪寄存器的值,常常被用于维护上下文等。在这里我们需要结合 gopanic 方法一同观察 recovery 方法。它所使用的栈指针 sp 和程序计数器 pc 是由当前 defer 在调用流程中的 deferproc 传递下来的,因此实际上最后是通过 gogo 方法跳回了 deferproc 方法。另外我们注意到:
在底层中程序将 gp.sched.ret 设置为了 1,也就是没有实际调用 deferproc 方法,直接修改了其返回值。意味着默认它已经处理完成。直接转移到 deferproc 方法的下一条指令去。至此为止,异常状态的流转控制就已经结束了。接下来就是继续走 defer 的流程了
为了验证这个想法,我们可以看一下核心的跳转方法 gogo ,代码如下:
通过查看代码可得知其主要作用是从 Gobuf 恢复状态。简单来讲就是将寄存器的值修改为对应 Goroutine(g) 的值,而在文中讲了很多次的 Gobuf,如下:
讲道理,其实它存储的就是 Goroutine 切换上下文时所需要的一些东西
拓展
实际上在调用 panic 和 recover 关键字时,是在编译阶段先转换为相应的 OPCODE 后,再由编译器转换为对应的运行时方法。并不是你所想像那样一步到位,有兴趣的小伙伴可以研究一下
总结
本文主要针对 panic 和 recover 关键字进行了深入源码的剖析,而开头的 4+1 个思考题,就是希望您能够带着疑问去学习,达到事半功倍的功效
另外本文和 defer 有一定的关联性,因此需要有一定的基础知识。若刚刚看的时候这部分不理解,学习后可以再读一遍加深印象
在最后,现在的你可以回答这几个思考题了吗?说出来了才是真的懂 :)
Last updated
Was this helpful?