蛮荆

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

2023-03-13

前言

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

  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
}

_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 方法恢复程序的继续执行
  3. gorecover 会进行参数校验,只有在 defer 语句中调用 recover, 才能通过参数校验 (详情见 gorecover 函数代码注释)
  4. 同上

转载申请

本作品采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接,图片在使用时请保留全部内容,商业转载请联系作者获得授权。