6.1 深入理解 Go panic and recover

作为一个 gophper,我相信你对于 panicrecover 肯定不陌生,但是你有没有想过。当我们执行了这两条语句之后。底层到底发生了什么事呢?前几天和同事刚好聊到相关的话题,发现其实大家对这块理解还是比较模糊的。希望这篇文章能够从更深入的角度告诉你为什么,它到底做了什么事?

思考

一、为什么会中止运行

  1. func main() {
  2. panic("EDDYCJY.")
  3. }

输出结果:

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

请思考一下,为什么执行 panic 后会导致应用程序运行中止?(而不是单单说执行了 panic 所以就结束了这么含糊)

二、为什么不会中止运行

  1. func main() {
  2. defer func() {
  3. if err := recover(); err != nil {
  4. log.Printf("recover: %v", err)
  5. }
  6. }()
  7. panic("EDDYCJY.")
  8. }

输出结果:

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

请思考一下,为什么加上 defer + recover 组合就可以保护应用程序?

三、不设置 defer 行不

上面问题二是 defer + recover 组合,那我去掉 defer 是不是也可以呢?如下:

  1. func main() {
  2. if err := recover(); err != nil {
  3. log.Printf("recover: %v", err)
  4. }
  5. panic("EDDYCJY.")
  6. }

输出结果:

  1. $ go run main.go
  2. panic: EDDYCJY.
  3. goroutine 1 [running]:
  4. main.main()
  5. /Users/eddycjy/go/src/github.com/EDDYCJY/awesomeProject/main.go:10 +0xa1
  6. exit status 2

竟然不行,啊呀毕竟入门教程都写的 defer + recover 组合 “万能” 捕获。但是为什么呢。去掉 defer 后为什么就无法捕获了?

请思考一下,为什么需要设置 deferrecover 才能起作用?

同时你还需要仔细想想,我们设置 defer + recover 组合后就能无忧无虑了吗,各种 “乱” 写了吗?

四、为什么起个 goroutine 就不行

  1. func main() {
  2. go func() {
  3. defer func() {
  4. if err := recover(); err != nil {
  5. log.Printf("recover: %v", err)
  6. }
  7. }()
  8. }()
  9. panic("EDDYCJY.")
  10. }

输出结果:

  1. $ go run main.go
  2. panic: EDDYCJY.
  3. goroutine 1 [running]:
  4. main.main()
  5. /Users/eddycjy/go/src/github.com/EDDYCJY/awesomeProject/main.go:14 +0x51
  6. exit status 2

请思考一下,为什么新起了一个 Goroutine 就无法捕获到异常了?到底发生了什么事…

源码

接下来我们将带着上述 4+1 个小思考题,开始对源码的剖析和分析,尝试从阅读源码中找到思考题的答案和更多为什么

数据结构

  1. type _panic struct {
  2. argp unsafe.Pointer
  3. arg interface{}
  4. link *_panic
  5. recovered bool
  6. aborted bool
  7. }

panic 中是使用 _panic 作为其基础单元的,每执行一次 panic 语句,都会创建一个 _panic。它包含了一些基础的字段用于存储当前的 panic 调用情况,涉及的字段如下:

  • argp:指向 defer 延迟调用的参数的指针
  • arg:panic 的原因,也就是调用 panic 时传入的参数
  • link:指向上一个调用的 _panic
  • recovered:panic 是否已经被处理,也就是是否被 recover
  • aborted:panic 是否被中止

另外通过查看 link 字段,可得知其是一个链表的数据结构,如下图:

image

恐慌 panic

  1. func main() {
  2. panic("EDDYCJY.")
  3. }

输出结果:

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

我们去反查一下 panic 处理具体逻辑的地方在哪,如下:

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

显然汇编代码直指内部实现是 runtime.gopanic,我们一起来看看这个方法做了什么事,如下(省略了部分):

  1. func gopanic(e interface{}) {
  2. gp := getg()
  3. ...
  4. var p _panic
  5. p.arg = e
  6. p.link = gp._panic
  7. gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
  8. for {
  9. d := gp._defer
  10. if d == nil {
  11. break
  12. }
  13. // defer...
  14. ...
  15. d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
  16. p.argp = unsafe.Pointer(getargp(0))
  17. reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz))
  18. p.argp = nil
  19. // recover...
  20. if p.recovered {
  21. ...
  22. mcall(recovery)
  23. throw("recovery failed") // mcall should not return
  24. }
  25. }
  26. preprintpanics(gp._panic)
  27. fatalpanic(gp._panic) // should not return
  28. *(*int)(nil) = 0 // not reached
  29. }
  • 获取指向当前 Goroutine 的指针
  • 初始化一个 panic 的基本单位 _panic 用作后续的操作
  • 获取当前 Goroutine 上挂载的 _defer(数据结构也是链表)
  • 若当前存在 defer 调用,则调用 reflectcall 方法去执行先前 defer 中延迟执行的代码,若在执行过程中需要运行 recover 将会调用 gorecover 方法
  • 结束前,使用 preprintpanics 方法打印出所涉及的 panic 消息
  • 最后调用 fatalpanic 中止应用程序,实际是执行 exit(2) 进行最终退出行为的

通过对上述代码的执行分析,可得知 panic 方法实际上就是处理当前 Goroutine(g) 上所挂载的 ._panic 链表(所以无法对其他 Goroutine 的异常事件响应),然后对其所属的 defer 链表和 recover 进行检测并处理,最后调用退出命令中止应用程序

无法恢复的恐慌 fatalpanic

  1. func fatalpanic(msgs *_panic) {
  2. pc := getcallerpc()
  3. sp := getcallersp()
  4. gp := getg()
  5. var docrash bool
  6. systemstack(func() {
  7. if startpanic_m() && msgs != nil {
  8. ...
  9. printpanics(msgs)
  10. }
  11. docrash = dopanic_m(gp, pc, sp)
  12. })
  13. systemstack(func() {
  14. exit(2)
  15. })
  16. *(*int)(nil) = 0
  17. }

我们看到在异常处理的最后会执行该方法,似乎它承担了所有收尾工作。实际呢,它是在最后对程序执行 exit 指令来达到中止运行的作用,但在结束前它会通过 printpanics 递归输出所有的异常消息及参数。代码如下:

  1. func printpanics(p *_panic) {
  2. if p.link != nil {
  3. printpanics(p.link)
  4. print("\t")
  5. }
  6. print("panic: ")
  7. printany(p.arg)
  8. if p.recovered {
  9. print(" [recovered]")
  10. }
  11. print("\n")
  12. }

所以不要以为所有的异常都能够被 recover 到,实际上像 fatal errorruntime.throw 都是无法被 recover 到的,甚至是 oom 也是直接中止程序的,也有反手就给你来个 exit(2) 教做人。因此在写代码时你应该要相对注意些,“恐慌” 是存在无法恢复的场景的

恢复 recover

  1. func main() {
  2. defer func() {
  3. if err := recover(); err != nil {
  4. log.Printf("recover: %v", err)
  5. }
  6. }()
  7. panic("EDDYCJY.")
  8. }

输出结果:

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

和预期一致,成功捕获到了异常。但是 recover 是怎么恢复 panic 的呢?再看看汇编代码,如下:

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

通过分析底层调用,可得知主要是如下几个方法:

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

在上小节中,我们讲述了简单的流程,gopanic 方法会调用当前 Goroutine 下的 defer 链表,若 reflectcall 执行中遇到 recover 就会调用 gorecover 进行处理,该方法代码如下:

  1. func gorecover(argp uintptr) interface{} {
  2. gp := getg()
  3. p := gp._panic
  4. if p != nil && !p.recovered && argp == uintptr(p.argp) {
  5. p.recovered = true
  6. return p.arg
  7. }
  8. return nil
  9. }

这代码,看上去挺简单的,核心就是修改 recovered 字段。该字段是用于标识当前 panic 是否已经被 recover 处理。但是这和我们想象的并不一样啊,程序是怎么从 panic 流转回去的呢?是不是在核心方法里处理了呢?我们再看看 gopanic 的代码,如下:

  1. func gopanic(e interface{}) {
  2. ...
  3. for {
  4. // defer...
  5. ...
  6. pc := d.pc
  7. sp := unsafe.Pointer(d.sp) // must be pointer so it gets adjusted during stack copy
  8. freedefer(d)
  9. // recover...
  10. if p.recovered {
  11. atomic.Xadd(&runningPanicDefers, -1)
  12. gp._panic = p.link
  13. for gp._panic != nil && gp._panic.aborted {
  14. gp._panic = gp._panic.link
  15. }
  16. if gp._panic == nil {
  17. gp.sig = 0
  18. }
  19. gp.sigcode0 = uintptr(sp)
  20. gp.sigcode1 = pc
  21. mcall(recovery)
  22. throw("recovery failed")
  23. }
  24. }
  25. ...
  26. }

我们回到 gopanic 方法中再仔细看看,发现实际上是包含对 recover 流转的处理代码的。恢复流程如下:

  • 判断当前 _panic 中的 recover 是否已标注为处理
  • _panic 链表中删除已标注中止的 panic 事件,也就是删除已经被恢复的 panic 事件
  • 将相关需要恢复的栈帧信息传递给 recovery 方法的 gp 参数(每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数的返回地址和局部变量)
  • 执行 recovery 进行恢复动作

从流程来看,最核心的是 recovery 方法。它承担了异常流转控制的职责。代码如下:

  1. func recovery(gp *g) {
  2. sp := gp.sigcode0
  3. pc := gp.sigcode1
  4. if sp != 0 && (sp < gp.stack.lo || gp.stack.hi < sp) {
  5. print("recover: ", hex(sp), " not in [", hex(gp.stack.lo), ", ", hex(gp.stack.hi), "]\n")
  6. throw("bad recovery")
  7. }
  8. gp.sched.sp = sp
  9. gp.sched.pc = pc
  10. gp.sched.lr = 0
  11. gp.sched.ret = 1
  12. gogo(&gp.sched)
  13. }

粗略一看,似乎就是很简单的设置了一些值?但实际上设置的是编译器中伪寄存器的值,常常被用于维护上下文等。在这里我们需要结合 gopanic 方法一同观察 recovery 方法。它所使用的栈指针 sp 和程序计数器 pc 是由当前 defer 在调用流程中的 deferproc 传递下来的,因此实际上最后是通过 gogo 方法跳回了 deferproc 方法。另外我们注意到:

  1. gp.sched.ret = 1

在底层中程序将 gp.sched.ret 设置为了 1,也就是没有实际调用 deferproc 方法,直接修改了其返回值。意味着默认它已经处理完成。直接转移到 deferproc 方法的下一条指令去。至此为止,异常状态的流转控制就已经结束了。接下来就是继续走 defer 的流程了

为了验证这个想法,我们可以看一下核心的跳转方法 gogo ,代码如下:

  1. // void gogo(Gobuf*)
  2. // restore state from Gobuf; longjmp
  3. TEXT runtime·gogo(SB),NOSPLIT,$8-4
  4. MOVW buf+0(FP), R1
  5. MOVW gobuf_g(R1), R0
  6. BL setg<>(SB)
  7. MOVW gobuf_sp(R1), R13 // restore SP==R13
  8. MOVW gobuf_lr(R1), LR
  9. MOVW gobuf_ret(R1), R0
  10. MOVW gobuf_ctxt(R1), R7
  11. MOVW $0, R11
  12. MOVW R11, gobuf_sp(R1) // clear to help garbage collector
  13. MOVW R11, gobuf_ret(R1)
  14. MOVW R11, gobuf_lr(R1)
  15. MOVW R11, gobuf_ctxt(R1)
  16. MOVW gobuf_pc(R1), R11
  17. CMP R11, R11 // set condition codes for == test, needed by stack split
  18. B (R11)

通过查看代码可得知其主要作用是从 Gobuf 恢复状态。简单来讲就是将寄存器的值修改为对应 Goroutine(g) 的值,而在文中讲了很多次的 Gobuf,如下:

  1. type gobuf struct {
  2. sp uintptr
  3. pc uintptr
  4. g guintptr
  5. ctxt unsafe.Pointer
  6. ret sys.Uintreg
  7. lr uintptr
  8. bp uintptr
  9. }

讲道理,其实它存储的就是 Goroutine 切换上下文时所需要的一些东西

拓展

  1. const(
  2. OPANIC // panic(Left)
  3. ORECOVER // recover()
  4. ...
  5. )
  6. ...
  7. func walkexpr(n *Node, init *Nodes) *Node {
  8. ...
  9. switch n.Op {
  10. default:
  11. Dump("walk", n)
  12. Fatalf("walkexpr: switch 1 unknown op %+S", n)
  13. case ONONAME, OINDREGSP, OEMPTY, OGETG:
  14. case OTYPE, ONAME, OLITERAL:
  15. ...
  16. case OPANIC:
  17. n = mkcall("gopanic", nil, init, n.Left)
  18. case ORECOVER:
  19. n = mkcall("gorecover", n.Type, init, nod(OADDR, nodfp, nil))
  20. ...
  21. }

实际上在调用 panicrecover 关键字时,是在编译阶段先转换为相应的 OPCODE 后,再由编译器转换为对应的运行时方法。并不是你所想像那样一步到位,有兴趣的小伙伴可以研究一下

总结

本文主要针对 panicrecover 关键字进行了深入源码的剖析,而开头的 4+1 个思考题,就是希望您能够带着疑问去学习,达到事半功倍的功效

另外本文和 defer 有一定的关联性,因此需要有一定的基础知识。若刚刚看的时候这部分不理解,学习后可以再读一遍加深印象

在最后,现在的你可以回答这几个思考题了吗?说出来了才是真的懂 :)