这一节中我们将介绍 Go 语言中两个经常成对出现的关键字 panic
和 recover
的实现原理,我们在上一节关注的 defer 与这里介绍的两个关键字其实也有着比较大的关系,我们会在剩下的部分展开介绍相关的内容,没有阅读 上一节 的读者还是需要补充一下相关知识,这样才能更好地了解
panic
和 recover
关键字的原理。
概述
在具体介绍和分析 Go 语言中的 panic
和 recover
的实现原理之前,我们首先需要对它们有一些基本的了解;panic
和 recover
两个关键字其实都是 Go 语言中的内置函数,panic
能够改变程序的控制流,当一个函数调用执行 panic
时,它会立刻停止执行函数中其他的代码,而是会运行其中的 defer
函数,执行成功后会返回到调用方。
对于上层调用方来说,调用导致 panic
的函数其实与直接调用 panic
类似,所以也会执行所有的 defer
函数并返回到它的调用方,这个过程会一直进行直到当前 Goroutine 的调用栈中不包含任何的函数,这时整个程序才会崩溃,这个『恐慌过程』不仅会被显式的调用触发,还会由于运行期间发生错误而触发。
然而 panic
导致的『恐慌』状态其实可以被 defer 中的 recover
中止,recover
是一个只在 defer
中能够发挥作用的函数,在正常的控制流程中,调用 recover
会直接返回 nil
并且没有任何的作用,但是如果当前的 Goroutine 发生了『恐慌』,recover
其实就能够捕获到 panic
抛出的错误并阻止『恐慌』的继续传播。
概述这一小节的内容,大部分直接来自于 Go 语言的博客 Defer, Panic, and Recover,文章介绍了三种 Go 语言的常见关键字的常见使用场景。
常见使用
我们简单举两个例子简单了解一下 panic
和 recover
关键字的原理,先来看第一个例子:
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
函数,这一点从 上一节 的源代码中也有所体现。
另一个例子就不止涉及 panic
和 defer
关键字了,我们可以看一下 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
函数也能够正常执行
实现原理
既然已经介绍完了现象并且已经对 panic
和 recover
有了一定的了解,接下来我们就会从 Go 语言的源代码层面对上一节中谈到的现象一探究竟,这一节接下来的内容就是介绍这两个函数的实现原理了,作为 Go 语言中的关键字,我们还是会从编译期间和运行时两方面介绍它们。
panic
和 recover
关键字会在 编译期间 被 Go 语言的编译器转换成 OPANIC
和 ORECOVER
类型的节点并进一步转换成 gopanic
和 gorecover
两个运行时的函数调用。
数据结构
panic
在 Golang 中其实是由一个数据结构表示的,每当我们调用一次 panic
函数都会创建一个如下所示的数据结构存储相关的信息:
type _panic struct {
argp unsafe.Pointer
arg interface{}
link *_panic
recovered bool
aborted bool
}
-
argp
是指向defer
调用时参数的指针; -
arg
是调用panic
时传入的参数; -
link
指向了更早调用的_panic
结构; -
recovered
表示当前_panic
是否被recover
恢复; -
aborted
表示当前的panic
是否被强行终止;
从数据结构中的 link
字段我们就可以推测出以下的结论 — panic
函数可以被连续多次调用,它们之间通过 link
的关联形成一个链表。
崩溃
首先了解一下没有被 recover
的 panic
函数是如何终止整个程序的,我们来看一下 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
函数执行过程包含以下几个步骤:
- 获取当前
panic
调用所在的 Goroutine 协程; - 创建并初始化一个
_panic
结构体; - 从当前 Goroutine 中的链表获取一个
_defer
结构体; - 如果当前
_defer
存在,调用reflectcall
执行_defer
中的代码; - 将下一位的
_defer
结构设置到 Goroutine 上并回到 3; - 调用
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
方法进行调度,调度之前会准备好 sp
、pc
以及函数的返回值:
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 语言中 panic
和 recover
的实现其实与 defer 关键字的联系非常紧密,而分析程序的恐慌和恢复过程也比较棘手,不是特别容易理解。在文章的最后我们还是简单总结一下具体的实现原理:
- 在编译过程中会将
panic
和recover
分别转换成gopanic
和gorecover
函数,同时将defer
转换成deferproc
函数并在调用defer
的函数和方法末尾增加deferreturn
的指令; - 在运行过程中遇到
gopanic
方法时,会从当前 Goroutine 中取出_defer
的链表并通过reflectcall
调用用于收尾的函数; - 如果在
reflectcall
调用时遇到了gorecover
就会直接将当前的_panic.recovered
标记成true
并返回panic
传入的参数(在这时recover
就能够获取到panic
的信息);- 在这次调用结束之后,
gopanic
会从_defer
结构体中取出程序计数器pc
和栈指针sp
并调用recovery
方法进行恢复; -
recovery
会根据传入的pc
和sp
跳转到deferproc
函数; - 编译器自动生成的代码会发现
deferproc
的返回值不为0
,这时就会直接跳到deferreturn
函数中并恢复到正常的控制流程(依次执行剩余的defer
并正常退出);
- 在这次调用结束之后,
- 如果没有遇到
gorecover
就会依次遍历所有的_defer
结构,并在最后调用fatalpanic
中止程序、打印panic
参数并返回错误码2
;
整个过程涉及了一些 Go 语言底层相关的知识并且发生了非常多的跳转,相关的源代码也不是特别的直接,阅读起来也比较晦涩,不过还是对我们理解 Go 语言的错误处理机制有着比较大的帮助。
Reference
关于图片和转载
本作品采用知识共享署名 4.0 国际许可协议进行许可。 转载时请注明原文链接,图片在使用时请保留图片中的全部内容,可适当缩放并在引用处附上图片所在的文章链接,图片使用 Sketch 进行绘制。
微信公众号
关于评论和留言
如果对本文 谈谈 panic 和 recover 的原理 的内容有疑问,请在下面的评论系统中留言,谢谢。原文链接:谈谈 panic 和 recover 的原理 · 面向信仰编程
Follow: Draveness · GitHub