go panic探索

2023-11-12 10:07:24 浏览数 (1)

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 函数实现。

代码语言:javascript复制
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 递归的逻辑;

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

代码语言:javascript复制
// 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 ,那么没有丝毫用途。

代码语言:javascript复制
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 投递到调度队列中。

代码语言:javascript复制
// 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

0 人点赞