为什么 recover 必须在 defer 中调用?

admin 2023年5月24日01:08:33评论20 views字数 3964阅读13分12秒阅读模式

前言

开始正文之前,先来看看几个有趣的小问题:

  1. 为什么 panic 可以让程序崩溃?
  2. 为什么 recover 可以捕获 panic 的消息并终止程序崩溃?
  3. 为什么 recover 必须在 defer 中调用?
  4. 为什么 recover 必须在 defer 中直接调用 (不能嵌套)?

内部实现

带着上面的几个小问题,我们从源代码的角度来探究一下, panicrecover 的实现相关文件目录为 $GOROOT/src/runtime,笔者的 Go 版本为 go1.19 linux/amd64

_panic 对象

_panic 对象表示 panic 语句 语句的运行时。

// runtime2.go

type _panic struct {
 argp      unsafe.Pointer // 指向调用 defer 时参数的指针
 arg       any            // 指向调用 panic 时传入的参数
 link      *_panic        // _panic 链表
 pc        uintptr        // panic 被捕获后,继续执行的程序 sp (栈底) 寄存器
 sp        unsafe.Pointer // panic 被捕获后,继续执行的程序 pc (程序计数器) 寄存器 (下一条汇编指令的地址)
 recovered bool           // 当前 panic 是否被捕获
 aborted   bool           // 当前 panic 是否被终止

 // pc、sp 和 goexit 三个字段都是为了修复 runtime.Goexit 带来的问题引入的
 // runtime.Goexit 能够只结束调用该函数的 goroutine 而不影响其他的 goroutine
 //  但是该函数会被 defer 中的 panic 和 recover 取消
 // 引入这三个字段就是为了保证 runtime.Goexit 函数一定会执行
 goexit    bool
}

为什么 recover 必须在 defer 中调用?

_panic 对象

gopanic 方法

gopanic 方法对应 panic 函数,编译器会将 panic 语句 转换为 gopanic 函数调用。

// panic.go

func gopanic(e any) {
 // 获取当前 G
 gp := getg()

 // 生成一个新的 _panic 对象
 var p _panic
 p.arg = e
 // 将 _panic 对象放在链表头部
 p.link = gp._panic
 gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

 for {
  // 获取 _defer 链表头节点
  d := gp._defer
  // 没有 _defer 对象, 自然也就没有 recover,直接跳出循环
  // 意味着程序没有捕获 panic, 然后崩溃
  if d == nil {
   break
  }

  // defer 语句已经执行过了
  // 如果 defer 是由之前的 panic 或 runtime.Goexit 执行的
  // 并且触发了新的 panic, 也就是 defer 函数里再次 panic
  // 将 defer 从列表中删除,之前的 panic 不会继续运行
  // 但需要确保之前的 runtime.Goexit 继续运行
  if d.started {
   if d._panic != nil {
    d._panic.aborted = true
   }
   d._panic = nil

   if !d.openDefer {
    d.fn = nil
    // 从 defer 链表中删除当前 defer 对象
    gp._defer = d.link
    freedefer(d)
    continue
   }
  }

  // 标记 defer 已经执行
  d.started = true

  // 如果在 defer 调用期间发生了新的 panic
  // 新的 panic 将在链表中找到当前 _defer
  // 标记 d._panic (指向当前的 panic) 终止
  d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
  d._panic = nil

  // 获取 pc, sp 寄存器的值
  pc := d.pc
  sp := unsafe.Pointer(d.sp)

  if p.recovered {
   // p.recovered 字段已经在 gorecover 函数中被修改为 true
   // 说明当前 panic 被捕获了
      // 从 panic 链表中删除当前 panic 对象
   gp._panic = p.link

   if gp._panic != nil && gp._panic.goexit && gp._panic.aborted {
    // 正常的恢复将绕过 Goexit
    // 非正常的情况交给 Goexit 处理
   }

   gp._panic = p.link
   // 如果链表的当前节点后面还有 _panic 对象
   // 并且被标记为终止了,将它们从链表中删除
   for gp._panic != nil && gp._panic.aborted {
    gp._panic = gp._panic.link
   }

   // 将捕获到的 panic 消息传递给 recover 函数
   gp.sigcode0 = uintptr(sp)
   gp.sigcode1 = pc
   // 恢复的时候 panic 函数将从此处跳出 (编译器实现)
   // gopanic 调用结束,下面的两行代码不会执行
   mcall(recovery)
   throw("recovery failed")
  }
 }

 fatalpanic(gp._panic)
}

fatalpanic 方法

fatalpanic 方法表示当 panic 没有被捕获时要执行的操作 (也就是结束程序)。

//go:nosplit
func fatalpanic(msgs *_panic) {
 systemstack(func() {
  // 程序结束错误码为 2
  exit(2)
 })
}

gorecover 方法

gorecover 方法对应 recover 函数,编译器会将 recover 语句转换为 gorecover 函数调用。

//go:nosplit
func gorecover(argp uintptr) any {
 // recover 函数必须在 defer 函数中调用
 // recover 函数必须从最顶层函数 (直接在 defer 语句或函数体中) 调用,也就是说不能出现 defer 嵌套
 // p.argp 是最顶层的 defer 函数调用的参数指针,与 panic 函数的调用方的参数进行比较
 // 如果匹配,调用方就可以 recover
 gp := getg()
 p := gp._panic

  // 只处理一个 _panic, 标记完就返回
  // 具体的捕获恢复处理逻辑在 gopanic 函数实现
 if p != nil && !p.goexit && !p.recovered && argp == uintptr(p.argp) {
  // 将 panic 标记为已捕获
  p.recovered = true
  // 返回 panic 的参数
  return p.arg
 }
 return nil
}

recovery 方法

recovery 方法用于恢复 goroutine 的继续执行,通过重置寄存器并将 goroutine 重新加入调度队列。

func recovery(gp *g) {
 // 恢复之前传入的 sp 和 pc
 sp := gp.sigcode0   // 取出栈 sp
 pc := gp.sigcode1   // 取出栈 pc

 gp.sched.sp = sp    // 重置栈 sp
 gp.sched.pc = pc    // 重置栈 pc
 gp.sched.lr = 0
 gp.sched.ret = 1
    gogo(&gp.sched)   // 加入调度队列
}

函数中的这句代码可以简单解释下:

gp.sched.ret = 1

这里并没有调用 deferproc 函数,但是直接修改了返回值,所以调度再次执行时会跳转到 deferproc 函数的下一条指令位置,设置为 1 是模拟 deferproc 函数返回值。

在上一篇分析 defer 源代码的时候,我们提到过:

如果 deferproc 返回值不等于 0, 说明 panic 被捕获到了
如果 deferproc 返回值等于 0, 说明 panic 没有被捕获

小结

panic + recover 的实现由编译器和运行时共同完成,通过对内部实现源代码的学习,我们可以更加深入理解 defer + panic+ recover 的内部实现, 现在来回答本文开头提到的几个小问题。

  1. panic 如果没有被捕获,最终会调用 exit(2) 终止程序运行 (也就是程序崩溃)
  2. recover 最终会调用 recovery 方法恢复程序的继续执行 (详情见 recovery 函数代码注释)
  3. gorecover 会进行参数校验,只有在 defer 语句中调用 recover, 才能通过参数校验 (详情见 gorecover 函数代码注释)
  4. 同上


为什么 recover 必须在 defer 中调用?
快点击阅读原文报名吧~~

原文始发于微信公众号(GoCN):为什么 recover 必须在 defer 中调用?

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2023年5月24日01:08:33
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   为什么 recover 必须在 defer 中调用?http://cn-sec.com/archives/1751218.html

发表评论

匿名网友 填写信息