go panic探索

panic 发生之后,如果 Go 不做任何特殊处理,默认行为是打印堆栈,退出程序。

panic 到底是什么?

  1. panic( ) 函数内部会产生一个关键的数据结构体 _panic ,并且挂接到 goroutine 之上;
  2. panic( ) 函数内部会执行 _defer 函数链条,并针对 _panic 的状态进行对应的处理;

什么叫做 panic( ) 的对应的处理?

循环执行 goroutine 上面的 _defer 函数链,如果执行完了都还没有恢复 _panic 的状态,那就没得办法了,退出进程,打印堆栈。
如果在 goroutine 的 _defer 链上,有个朋友 recover 了一下,把这个 _panic 标记成恢复,那事情就到此为止,就从这个 _defer 函数执行后续正常代码即可,走 deferreturn 的逻辑。

recover 函数

recover 对应了 runtime/panic.go 中的 gorecover 函数实现。

1
2
3
4
5
6
7
8
9
10
func gorecover(argp uintptr) interface{} {
// 只处理 gp._panic 链表最新的这个 _panic;
gp := getg()
p := gp._panic
if p != nil && !p.recovered && argp == uintptr(p.argp) {
p.recovered = true
return p.arg
}
return nil
}

这个函数可太简单了:

  1. 取出当前 goroutine 结构体;
  2. 取出当前 goroutine 的 _panic 链表最新的一个 _panic,如果是非 nil 值,则进行处理;
  3. 该 _panic 结构体的 recovered 赋值 true,程序返回;

这就是 recover 函数的全部内容,只给 _panic.recovered 赋值而已,不涉及代码的神奇跳转。而 _panic.recovered 的赋值是在 panic 函数逻辑中发挥作用。

panic函数

panic 的实现在一个叫做 gopanic 的函数,位于 runtime/panic.go 文件。panic 机制最重要最重要的就是 gopanic 函数了,所有的 panic 细节尽在此。为什么 panic 会显得晦涩,主要有两个点:

  1. 嵌套 panic 的时候,gopanic 会有递归执行的场景;
  2. 程序指令跳转并不是常规的函数压栈,弹栈,在 recovery 的时候,是直接修改指令寄存器的结构体,从而直接越过了 gopanic 后面的逻辑,甚至是多层 gopanic 递归的逻辑;

一切秘密都在下面这个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// runtime/panic.go
func gopanic(e interface{}) {
// 在栈上分配一个 _panic 结构体
var p _panic
// 把当前最新的 _panic 挂到链表最前面
p.link = gp._panic
gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

for {
// 取出当前最近的 defer 函数;
d := gp._defer
if d == nil {
// 如果没有 defer ,那就没有 recover 的时机,只能跳到循环外,退出进程了;
break
}

// 进到这个逻辑,那说明了之前是有 panic 了,现在又有 panic 发生,这里一定处于递归之中;
if d.started {
if d._panic != nil {
d._panic.aborted = true
}
// 把这个 defer 从链表中摘掉;
gp._defer = d.link
freedefer(d)
continue
}

// 标记 _defer 为 started = true (panic 递归的时候有用)
d.started = true
// 记录当前 _defer 对应的 panic
d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

// 执行 defer 函数
reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz))

// defer 执行完成,把这个 defer 从链表里摘掉;
gp._defer = d.link

// 取出 pc,sp 寄存器的值;
pc := d.pc
sp := unsafe.Pointer(d.sp)
// 如果 _panic 被设置成恢复,那么到此为止;
if p.recovered {
// 摘掉当前的 _panic
gp._panic = p.link
// 如果前面还有 panic,并且是标记了 aborted 的,那么也摘掉;
for gp._panic != nil && gp._panic.aborted {
gp._panic = gp._panic.link
}
// panic 的流程到此为止,恢复到业务函数堆栈上执行代码;
gp.sigcode0 = uintptr(sp)
gp.sigcode1 = pc
// 注意:恢复的时候 panic 函数将从此处跳出,本 gopanic 调用结束,后面的代码永远都不会执行。
mcall(recovery)
throw("recovery failed") // mcall should not return
}
}

// 打印错误信息和堆栈,并且退出进程;
preprintpanics(gp._panic)
fatalpanic(gp._panic) // should not return
*(*int)(nil) = 0 // not reached
}

上面逻辑可以拆分为循环内和循环外两部分去理解:

  • 循环内:程序执行 defer,是否恢复正常的指令执行,一切都在循环内决定;
  • 循环外:一旦走到循环外,说明 _panic 没人处理,程序即将退出;

for 循环内

循环内的事情拆解成:

  1. 遍历 goroutine 的 defer 链表,获取到一个 _defer 延迟函数;
  2. 获取到 _defer 延迟函数,设置标识 d.started,绑定当前 d._panic(用以在递归的时候判断);
  3. 执行 _defer 延迟函数;
  4. 摘掉执行完的 _defer 函数;
  5. 判断 _panic.recovered 是否设置为 true,进行相应操作;
    1. 如果是 true 那么重置 pc,sp 寄存器(一般从 deferreturn 指令前开始执行),goroutine 投递到调度队列,等待执行;
  6. 重复以上步骤;

问题一:为什么 recover 一定要放在 defer 里面才生效?

因为,这是唯一的修改 _panic.recovered 字段的时机 !

为什么 recover 已经放在 defer 里面,但是进程还是没有恢复?

划重点:在 gopanic 里,只遍历执行当前 goroutine 上的 _defer 函数链条。所以,如果挂在其他 goroutine 的 defer 函数做了 recover ,那么没有丝毫用途。

1
2
3
4
5
6
7
8
func main() { // g1
go func() { // g2
defer func() {
recover()
}()
}()
panic("test")
}

因为,panic 和 recover 在两个不同的 goroutine,_panic 是挂在 g1 上的,recover 是在 g2 的 _defer 链条里。
gopanic 遍历的是 g1 的 _defer 函数链表,跟 g2 八杆子打不着,g2 的 recover 自然拿不到 g1 的 _panic 结构,自然也不能设置 recovered 为 true ,所以程序还是崩了。

recover 函数
在 gopanic 函数中,在循环执行 defer 函数的时候,如果发现 _panic.recovered 字段被设置成 true 的时候,调用 mcall(recovery) 来执行所谓的恢复。

看一眼 recovery 函数的实现,这个函数极其简单,就是恢复 pc,sp 寄存器,重新把 Goroutine 投递到调度队列中。

1
2
3
4
5
6
7
8
9
10
11
// runtime/panic.go
func recovery(gp *g) {
// 取出栈寄存器和程序计数器的值
sp := gp.sigcode0
pc := gp.sigcode1
// 重置 goroutine 的 pc,sp 寄存器;
gp.sched.sp = sp
gp.sched.pc = pc
// 重新投入调度队列
gogo(&gp.sched)
}

总结

  1. panic() 会退出进程,是因为调用了 exit 的系统调用;
  2. recover() 所在的 defer 函数必须和 panic 都是挂在同一个goroutine 上,不能跨协程,因为 gopanic 只会执行当前 goroutine 的延迟函数;

参考
深度细节 | Go 的 panic 的秘密都在这
源码剖析panic与recover

分享到 评论