谈谈 panic 和 recover 的原理

1,658 阅读9分钟

这一节中我们将介绍 Go 语言中两个经常成对出现的关键字 panicrecover 的实现原理,我们在上一节关注的 defer 与这里介绍的两个关键字其实也有着比较大的关系,我们会在剩下的部分展开介绍相关的内容,没有阅读 上一节 的读者还是需要补充一下相关知识,这样才能更好地了解 panicrecover 关键字的原理。

概述

在具体介绍和分析 Go 语言中的 panicrecover 的实现原理之前,我们首先需要对它们有一些基本的了解;panicrecover 两个关键字其实都是 Go 语言中的内置函数,panic 能够改变程序的控制流,当一个函数调用执行 panic 时,它会立刻停止执行函数中其他的代码,而是会运行其中的 defer 函数,执行成功后会返回到调用方。

golang-pani

对于上层调用方来说,调用导致 panic 的函数其实与直接调用 panic 类似,所以也会执行所有的 defer 函数并返回到它的调用方,这个过程会一直进行直到当前 Goroutine 的调用栈中不包含任何的函数,这时整个程序才会崩溃,这个『恐慌过程』不仅会被显式的调用触发,还会由于运行期间发生错误而触发。

然而 panic 导致的『恐慌』状态其实可以被 defer 中的 recover 中止,recover 是一个只在 defer 中能够发挥作用的函数,在正常的控制流程中,调用 recover 会直接返回 nil 并且没有任何的作用,但是如果当前的 Goroutine 发生了『恐慌』,recover 其实就能够捕获到 panic 抛出的错误并阻止『恐慌』的继续传播。

概述这一小节的内容,大部分直接来自于 Go 语言的博客 Defer, Panic, and Recover,文章介绍了三种 Go 语言的常见关键字的常见使用场景。

常见使用

我们简单举两个例子简单了解一下 panicrecover 关键字的原理,先来看第一个例子:

func main() {
	defer println("in main")
	go func() {
		defer println("in goroutine")
		panic("")
	}()

	time.Sleep(1 * time.Second)
}

// in goroutine
// panic:
// ...

当我们运行这段代码时,其实会发现 main 函数中的 defer 语句并没有执行,执行的其实只有 Goroutine 中的 defer,这其实就印证了 Go 语言在发生 panic 时只会执行当前协程中的 defer 函数,这一点从 上一节 的源代码中也有所体现。

另一个例子就不止涉及 panicdefer 关键字了,我们可以看一下 recover 是如何让当前函数重新『走向正轨』的:

func main() {
	defer fmt.Println("in main")
	defer func() {
		if err := recover(); err != nil {
			fmt.Println(err)
		}
	}()

	panic("unknown err")
}

// unknown err
// in main

从这个例子中我们可以看到,recover 函数其实只是阻止了当前程序的崩溃,但是当前控制流中的其他 defer 函数还会正常执行。

在最后,我们需要知道的是可以在 defer 中连续多次调用 panic 函数,这是一个 Go 语言中 panic 比较有意思的现象:

func main() {
	defer fmt.Println("in main")
	defer func() {
		panic("panic again")
	}()

	panic("panic once")
}

// in main
// panic: unknown err
// 	panic: again
// 
// goroutine 1 [running]:
// main.main.func1()
// ...

当我们运行上述代码时,从打印出的结果中可以看到当前的函数确实经历了两次 panic,并且最外层的 defer 函数也能够正常执行

实现原理

既然已经介绍完了现象并且已经对 panicrecover 有了一定的了解,接下来我们就会从 Go 语言的源代码层面对上一节中谈到的现象一探究竟,这一节接下来的内容就是介绍这两个函数的实现原理了,作为 Go 语言中的关键字,我们还是会从编译期间和运行时两方面介绍它们。

panicrecover 关键字会在 编译期间 被 Go 语言的编译器转换成 OPANICORECOVER 类型的节点并进一步转换成 gopanicgorecover 两个运行时的函数调用。

数据结构

panic 在 Golang 中其实是由一个数据结构表示的,每当我们调用一次 panic 函数都会创建一个如下所示的数据结构存储相关的信息:

type _panic struct {
	argp      unsafe.Pointer
	arg       interface{}
	link      *_panic
	recovered bool
	aborted   bool
}
  1. argp 是指向 defer 调用时参数的指针;
  2. arg 是调用 panic 时传入的参数;
  3. link 指向了更早调用的 _panic 结构;
  4. recovered 表示当前 _panic 是否被 recover 恢复;
  5. aborted 表示当前的 panic 是否被强行终止;

从数据结构中的 link 字段我们就可以推测出以下的结论 — panic 函数可以被连续多次调用,它们之间通过 link 的关联形成一个链表。

崩溃

首先了解一下没有被 recoverpanic 函数是如何终止整个程序的,我们来看一下 gopanic 函数的实现

func gopanic(e interface{}) {
	gp := getg()
	// ...
	var p _panic
	p.arg = e
	p.link = gp._panic
	gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

	for {
		d := gp._defer
		if d == nil {
			break
		}

		d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

		p.argp = unsafe.Pointer(getargp(0))
		reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz))
		p.argp = nil

		d._panic = nil
		d.fn = nil
		gp._defer = d.link

		pc := d.pc
		sp := unsafe.Pointer(d.sp)
		freedefer(d)
		if p.recovered {
			// ...
		}
	}

	fatalpanic(gp._panic)
	*(*int)(nil) = 0
}

我们暂时省略了 recover 相关的代码,省略后的 gopanic 函数执行过程包含以下几个步骤:

  1. 获取当前 panic 调用所在的 Goroutine 协程;
  2. 创建并初始化一个 _panic 结构体;
  3. 从当前 Goroutine 中的链表获取一个 _defer 结构体;
  4. 如果当前 _defer 存在,调用 reflectcall 执行 _defer 中的代码;
  5. 将下一位的 _defer 结构设置到 Goroutine 上并回到 3;
  6. 调用 fatalpanic 中止整个程序;

fatalpanic 函数在中止整个程序之前可能就会通过 printpanics 打印出全部的 panic 消息以及调用时传入的参数:

func fatalpanic(msgs *_panic) {
	pc := getcallerpc()
	sp := getcallersp()
	gp := getg()
	var docrash bool
	systemstack(func() {
		if startpanic_m() && msgs != nil {
			atomic.Xadd(&runningPanicDefers, -1)

			printpanics(msgs)
		}
		docrash = dopanic_m(gp, pc, sp)
	})

	if docrash {
		crash()
	}

	systemstack(func() {
		exit(2)
	})

	*(*int)(nil) = 0 // not reached
}

fatalpanic 函数的最后会通过 exit 退出当前程序并返回错误码 2,不同的操作系统其实对 exit 函数有着不同的实现,其实最终都执行了 exit 系统调用来退出程序。

恢复

到了这里我们已经掌握了 panic 退出程序的过程,但是一个 panic 的程序也可能会被 defer 中的关键字 recover 恢复,在这时我们就回到 recover 关键字对应函数 gorecover 的实现了:

func gorecover(argp uintptr) interface{} {
	p := gp._panic
	if p != nil && !p.recovered && argp == uintptr(p.argp) {
		p.recovered = true
		return p.arg
	}
	return nil
}

这个函数的实现其实非常简单,它其实就是会修改 panic 结构体的 recovered 字段,当前函数的调用其实都发生在 gopanic 期间,我们重新回顾一下这段方法的实现:

func gopanic(e interface{}) {
	// ...

	for {
		// reflectcall

		pc := d.pc
		sp := unsafe.Pointer(d.sp)

		// ...
		if p.recovered {
			gp._panic = p.link
			for gp._panic != nil && gp._panic.aborted {
				gp._panic = gp._panic.link
			}
			if gp._panic == nil {
				gp.sig = 0
			}
			gp.sigcode0 = uintptr(sp)
			gp.sigcode1 = pc
			mcall(recovery)
			throw("recovery failed")
		}
	}

	fatalpanic(gp._panic)
	*(*int)(nil) = 0
}

上述这段代码其实从 _defer 结构体中取出了程序计数器 pc 和栈指针 sp 并调用 recovery 方法进行调度,调度之前会准备好 sppc 以及函数的返回值:

func recovery(gp *g) {
	sp := gp.sigcode0
	pc := gp.sigcode1

	gp.sched.sp = sp
	gp.sched.pc = pc
	gp.sched.lr = 0
	gp.sched.ret = 1
	gogo(&gp.sched)
}

defer 一节中我们曾经介绍过 deferproc 的实现,作为创建并初始化 _defer 结构体的函数,它会将 deferproc 函数开始位置对应的栈指针 sp 和程序计数器 pc 存储到 _defer 结构体中,这里的 gogo 函数其实就会跳回 deferproc

TEXT runtime·gogo(SB), NOSPLIT, $8-4
	MOVL	buf+0(FP), BX		// gobuf
	MOVL	gobuf_g(BX), DX
	MOVL	0(DX), CX		// make sure g != nil
	get_tls(CX)
	MOVL	DX, g(CX)
	MOVL	gobuf_sp(BX), SP	// restore SP
	MOVL	gobuf_ret(BX), AX
	MOVL	gobuf_ctxt(BX), DX
	MOVL	$0, gobuf_sp(BX)	// clear to help garbage collector
	MOVL	$0, gobuf_ret(BX)
	MOVL	$0, gobuf_ctxt(BX)
	MOVL	gobuf_pc(BX), BX
	JMP	BX

这里的调度其实会将 deferproc 函数的返回值设置成 1,在这时编译器生成的代码就会帮助我们直接跳转到调用方函数 return 之前并进入 deferreturn 的执行过程,我们可以从 deferproc 的注释中简单了解这一过程:

func deferproc(siz int32, fn *funcval) {
	// ...

	// deferproc returns 0 normally.
	// a deferred func that stops a panic
	// makes the deferproc return 1.
	// the code the compiler generates always
	// checks the return value and jumps to the
	// end of the function if deferproc returns != 0.
	return0()
	// No code can go here - the C return register has
	// been set and must not be clobbered.
}

跳转到 deferreturn 函数之后,程序其实就从 panic 的过程中跳出来恢复了正常的执行逻辑,而 gorecover 函数也从 _panic 结构体中取出了调用 panic 时传入的 arg 参数。

总结

Go 语言中 panicrecover 的实现其实与 defer 关键字的联系非常紧密,而分析程序的恐慌和恢复过程也比较棘手,不是特别容易理解。在文章的最后我们还是简单总结一下具体的实现原理:

  1. 在编译过程中会将 panicrecover 分别转换成 gopanicgorecover函数,同时将 defer 转换成 deferproc 函数并在调用 defer 的函数和方法末尾增加 deferreturn 的指令;
  2. 在运行过程中遇到 gopanic 方法时,会从当前 Goroutine 中取出 _defer 的链表并通过 reflectcall 调用用于收尾的函数;
  3. 如果在 reflectcall 调用时遇到了 gorecover 就会直接将当前的 _panic.recovered 标记成 true 并返回 panic 传入的参数(在这时 recover 就能够获取到 panic 的信息);
    1. 在这次调用结束之后,gopanic 会从 _defer 结构体中取出程序计数器 pc 和栈指针 sp 并调用 recovery 方法进行恢复;
    2. recovery 会根据传入的 pcsp 跳转到 deferproc 函数;
    3. 编译器自动生成的代码会发现 deferproc 的返回值不为 0,这时就会直接跳到 deferreturn 函数中并恢复到正常的控制流程(依次执行剩余的 defer 并正常退出);
  4. 如果没有遇到 gorecover 就会依次遍历所有的 _defer 结构,并在最后调用 fatalpanic 中止程序、打印 panic 参数并返回错误码 2

整个过程涉及了一些 Go 语言底层相关的知识并且发生了非常多的跳转,相关的源代码也不是特别的直接,阅读起来也比较晦涩,不过还是对我们理解 Go 语言的错误处理机制有着比较大的帮助。

Reference

关于图片和转载

知识共享许可协议
本作品采用知识共享署名 4.0 国际许可协议进行许可。 转载时请注明原文链接,图片在使用时请保留图片中的全部内容,可适当缩放并在引用处附上图片所在的文章链接,图片使用 Sketch 进行绘制。

微信公众号

wechat-account-qrcode

关于评论和留言

如果对本文 谈谈 panic 和 recover 的原理 的内容有疑问,请在下面的评论系统中留言,谢谢。

原文链接:谈谈 panic 和 recover 的原理 · 面向信仰编程

Follow: Draveness · GitHub