panic

Go 运行时错误会引起 painc 异常。一般而言,当 panic 异常发生时,程序会中断运行,并立即执行在该 goroutine 中被延迟的函数(defer 机制)。随后,程序崩溃并输出日志信息。

由于 panic 会引起程序的崩溃,因此 panic 一般用于严重错误,如程序内部的逻辑不一致。但是对于大部分漏洞,我们应该使用 Go 提供的错误机制,而不是 panic,尽量避免程序的崩溃。

ℹ️
  • panic 只会触发当前 goroutine 的延迟函数调用;
  • recover 只有在 defer 函数中调用才会生效;
  • panic 允许在 defer 中嵌套多次调用,只有最后一个会被 recover 捕获;

panic 函数

panic 函数接受任何值作为参数。当某些不应该发生的场景发生时,就应该调用 panic

panic 详情中都有什么

panic: runtime error: index out of range

goroutine 1 [running]:
main.main()
/Users/haolin/GeekTime/Golang_Puzzlers/src/puzzlers/article19/q0/demo47.go:5 +0x3d
exit status 2

第一行是 panic: runtime error: index out of range。其中的 runtime error 的含义是,这是一个 runtime 代码包中抛出的 panic

goroutine 1 [running],它表示有一个 ID 为1的 goroutine 在此 panic 被引发的时候正在运行。这里的 ID 其实并不重要。

main.main() 表明了这个 goroutine 包装的 go 函数就是命令源码文件中的那个main函数,也就是说这里的 goroutine 正是主 goroutine

再下面的一行,指出的就是这个 goroutine 中的哪一行代码在此 panic 被引发时正在执行。含了此行代码在其所属的源码文件中的行数,以及这个源码文件的绝对路径。

+0x3d 代表的是:此行代码相对于其所属函数的入口程序计数偏移量。用处并不大。

exit status 2 表明我的这个程序是以退出状态码2结束运行的。在大多数操作系统中,只要退出状态码不是 0,都意味着程序运行的非正常结束。在 Go 语言中,因 panic 导致程序结束运行的退出状态码一般都会是 2

从 panic 被引发到程序终止运行的大致过程是什么

  1. 此行代码所属函数的执行随即终止。
  2. 紧接着,控制权并不会在此有片刻停留,它又会立即转移至再上一级的调用代码处。控制权如此一级一级地沿着调用栈的反方向传播至顶端,也就是我们编写的最外层函数那里。

这里的最外层函数指的是 go 函数,对于主 goroutine 来说就是 main 函数。但是控制权也不会停留在那里,而是被 Go 语言运行时系统收回。

随后,程序崩溃并终止运行,承载程序这次运行的进程也会随之死亡并消失。与此同时,在这个控制权传播的过程中,panic 详情会被逐渐地积累和完善,并会在程序终止之前被打印出来。

怎样让 panic 包含一个值,以及应该让它包含什么样的值

其实很简单,在调用 panic 函数时,把某个值作为参数传给该函数就可以了。panic 函数的唯一一个参数是空接口 (也就是interface{})类型的,所以从语法上讲,它可以接受任何类型的值。

但是,我们最好传入 error 类型的错误值,或者其他的可以被有效序列化的值。这里的“有效序列化”指的是,可以更易读地去表示形式转换

recover 捕获异常

一般情况下,不能因为某个处理函数引发的 panic 异常,杀掉整个进程,可以使用 recover 函数恢复 panic 异常。

panic 时会调用 recover,但是 recover 不能滥用,可能会引起资源泄漏或者其他问题。可以将 panic value 设置成特殊类型,来标识某个 panic 是否应该被恢复。recover 只能在 defer 修饰的函数中使用:

func soleTitle(doc *html.Node) (title string, err error) {
	type bailout struct{}
	defer func() {
		switch p := recover(); p {
            case nil:       // no panic
            case bailout{}: // "expected" panic
                err = fmt.Errorf("multiple title elements")
            default:
                panic(p) // unexpected panic; carry on panicking
		}
	}()
    panic(bailout{}) 
}

上面的代码,deferred 函数调用 recover,并检查 panic value

  • panic valuebailout{} 类型时,deferred 函数生成一个 error 返回给调用者。
  • panic value 是其他 non-nil 值时,表示发生了未知的 panic 异常。

正确使用 recover 函数

先调用 recover 函数,再调用 panic 函数会怎么样呢?

如果在调用 recover 函数时未发生 panic,那么该函数就不会做任何事情,并且只会返回一个 nil

defer 语句调用 recover 函数才是正确的打开方式

要注意,尽量把 defer 语句写在函数体的开始处,因为在引发 panic 的语句之后的所有语句,都不会有任何执行机会

ℹ️
注意连续调用 panic 只有最后一个会被 recover 捕获

panic 和 recover 原理

panic 能够改变程序的控制流:

  1. 当一个函数中调用 panic 时会立刻停止执行该函数的其他代码,并在执行结束后在当前 goroutine 中递归执行调用方的延迟函数调用 defer
  2. recover 可以中止 panic 造成的程序崩溃。它是一个只能在 defer 中发挥作用的函数,在其他作用域中调用不会发挥任何作用;

defer 关键字对应的 runtime.deferproc 会将延迟调用函数与调用方所在 goroutine 进行关联。所以当程序发生崩溃时只会调用当前 goroutine 的延迟调用函数。

多次调用 panic 不会影响 defer 函数的正常执行。

数据结构 runtime._panic

type _panic struct {
    argp      unsafe.Pointer
    arg       interface{}
    link      *_panic
    recovered bool
    aborted   bool

    pc        uintptr
    sp        unsafe.Pointer
    goexit    bool
}

崩溃恢复

编译器会将关键字 recover 转换成 runtime.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
}
  1. 如果当前 goroutine 没有调用 panic,那么该函数会直接返回 nil,这也是崩溃恢复在非 defer 中调用会失效的原因。
  2. 修改 runtime._panic 结构体的 recovered 字段。

runtime.gorecover 函数本身不包含恢复程序的逻辑,程序的恢复也是由 runtime.gopanic 函数负责的:

  1. 创建新的 runtime._panic 结构并添加到所在 goroutine._panic 链表的最前面;
  2. 在循环中不断从当前 goroutine 的 _defer 中链表获取 runtime._defer 并调用 runtime.reflectcall 运行延迟调用函数;
  3. 调用 runtime.fatalpanic 中止整个程序;
func gopanic(e interface{}) {
    gp := getg()
	// ...
    // 创建新的 runtime._panic 并添加到所在 goroutine 的 _panic 链表的最前面
	var p _panic
	p.arg = e
	p.link = gp._panic
	gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
    
    // 在循环中不断从当前 goroutine 的 _defer 中链表获取 runtime._defer 
    // 并调用 runtime.reflectcall 运行延迟调用函数
    for {
        // 执行延迟调用函数,可能会设置 p.recovered = true
        d := gp._defer
		if d == nil {
			break
		}

		d._panic = (*_panic)(noescape(unsafe.Pointer(&p))) // 关联 defer 和 panic
        // 运行延迟调用函数,如果碰到了 recover 函数,就会将 p.recovered 标记成 true
		reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz))

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

		freedefer(d)
        // 从 _defer 中取出了程序计数器 pc 和栈指针 sp 
        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 了从当前 goroutine 切换到 g0
            // 调用 runtime.recovery 函数触发 goroutine 的调度
            mcall(recovery)
            throw("recovery failed")
        }
    }

    // 调用 runtime.fatalpanic 中止整个程序,最终会调用 exit(2)
    // 这是为什么连续调用 panic 只有最后一个会被 recover 捕获的原因
    fatalpanic(gp._panic)
    // ...
}


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
    // 调用 defer 关键字时,调用时的栈指针 sp 和程序计数器 pc 就已经存储到了 runtime._defer 结构体中
    // 这里的 runtime.gogo 函数会跳回 defer 关键字调用的位置
    // gogo 函数从 g0 切换到调用 defer 的 goroutine
	gogo(&gp.sched)
}

崩溃恢复流程

  1. 编译器会负责做转换关键字的工作;
    • panicrecover 分别转换成 runtime.gopanicruntime.gorecover
    • defer 转换成 deferproc 函数;
  2. 在调用 defer 的函数末尾插入 deferreturn 函数;
  3. 在运行过程中遇到 gopanic 方法时,会从 goroutine 的链表依次取出 _defer 结构体并执行;
    • 如果调用延迟执行函数时遇到了 gorecover 就会将 _panic.recovered 标记成 true 并返回 panic 的参数;
    • 在这次调用结束之后,gopanic 会从 _defer 结构体中取出程序计数器 pc 和栈指针 sp 并调用 recovery 函数进行恢复程序;
    • recovery 会根据传入的 pcsp 跳转回 deferproc
    • 编译器自动生成的代码会发现 deferproc 的返回值不为 0,这时会跳回 deferreturn 并恢复到正常的执行流程;
  4. 如果没有遇到 gorecover 就会依次遍历所有的 _defer 结构,并在最后调用 fatalpanic 中止程序、打印 panic 的参数并返回错误码 2;
最后更新于