5 Panic & Defer
本文为极客时间《Go语言核心36讲》的学习笔记,梳理了相关的知识点。
1. 关键字 Panic 和 Recover
通常 panic 和 recover 是用来处理异常问题的。我们来综述下,他们各自的特点:
- painc 可以是系统出现严重错误时产生,也可以人为调用painc函数;如果不加处理,painc会沿着调用栈层层上报,直到程序崩溃终止。
- recover 可以回收 panic,返回一个空接口,但是必须要在defer 中才行。
1.1. Panic 的细节与注意事项
1.1.1. 不会影响其他GoRoutine
func main(){
defer println("in main")
go func() {
defer println("in goroutine")
panic("panic")
}()
time.Sleep(1 * time.Second)
}
in goroutine
panic: panic
...
exit status 2 //panic 一般都是错误码 2
提示
可以注释掉这些defer ,看下他的打印结果,很直观。
在GoRoutine中产生了panic,只会执行当前GoRoutine中的defer方法,不会触发main中的(实际上可以简单理解为,go func() 本身就是这个GoRoutine的main 函数)。如果把GoRoutine中的defer方法注释掉,依然不会触发main中的defer,系统还是会崩溃。原因是:defer 关键字对应的runtime.deferproc 会将延迟调用函数与调用方所在 GoRoutine 进行关联。
1.1.2. Panic的结构
//源代码在 go/src/runtime/runtime2.go 899行
type _panic struct {
argp unsafe.Pointer // pointer to arguments of deferred call run during panic; cannot move - known to liblink
arg interface{} // argument to panic
link *_panic // link to earlier panic
pc uintptr // where to return to in runtime if this panic is bypassed
sp unsafe.Pointer // where to return to in runtime if this panic is bypassed
recovered bool // whether this panic is over
aborted bool // the panic was aborted
goexit bool
}
根据注释,我们大致可以清楚:
- Link 会指向最近的panic,形成一个链表
- recovered 和 aborted 是两个标记位
- pc 和 sp 参与到GoRoutine相关
1.1.3. 运行流程
编译器会把painc 转化为 gopanic 函数。具体的逻辑会在这个方法里执行。我这里只把里面的部分代码拿出来。这里面有非常详细的注释,很方便阅读。
// 代码在 go/src/runtime/panic.go 887 行
func gopanic(e interface{}) {
gp := getg()
//创建一个新的painc 并把他加到链表的最前端
var p _panic
p.arg = e
p.link = gp._panic
gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
//循环defer链表, 并调用延迟函数。
for {
d := gp._defer
...
d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
...
freedefer(d)
if p.recovered {
...
}
...
}
...
fatalpanic(gp._panic) // 最终处理方法
}
接下来,看下fatalpanic函数的情况:
// 代码在 go/src/runtime/panic.go 1187 行
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) //打印出全部的panic信息,包括调用信息
}
docrash = dopanic_m(gp, pc, sp) //这里这方法很迷惑,不过看起来是针对goruntine 进行的操作
})
if docrash {
crash()
}
systemstack(func() {
exit(2) //如果panic没有被恢复,那么就会在这里退出程序,错误码 2
})
}
1.2. Recover 的细节与注意事项
Recover 函数比较简单,这个东西就是用来处理Panic的没有其他的用途。我们直接看下源码:
// 代码在 go/src/runtime/panic.go 1082 行
func gorecover(argp uintptr) interface{} {
gp := getg()
p := gp._panic
if p != nil && !p.goexit && !p.recovered && argp == uintptr(p.argp) {
//这里只有存在Painc的时候,才会运行。
p.recovered = true
return p.arg
}
//如果当前goruntine里没有panic 直接返回nil
return nil
}
这里的逻辑是把_panic里的recovered设置成true,没有多余操作。之后回到gopanic函数中,经过简单处理跳转到recovery中。再往后就是 defer的逻辑,一直走到deferreturn,回到正常的逻辑中。
到这里基本可以总结一下Panic和Recover的一些特点。
- Panic 是成链状保存的,并且Panic 只会触发自己所在的GoRoutine中的Defer里的Recover函数。
- Recover函数只能在Defer中,并且存在一个Painc的时候才会生效,这个在他的源代码有体现。他处理的事情非常简单,就是把Panic的recovered属性设置为true。
- 执行Panic的是runtime.gopanic函数。在这里,他依赖Defer,也会响应Recover的操作。在异常被恢复情况下,会一直走到deferreturn中,最终恢复现场。否则,会执行fatalpanic函数,打印出调用栈和异常信息,最后系统退出,错误码2。
通过学习源码,基本上解释了“panic 详情会在控制权传播的过程中,被逐渐地积累和完善,并且,控制权会一级一级地沿着调用栈的反方向传播至顶端。”
2. 关键字 Defer
Defer,人如其名,延迟执行函数。延迟到什么时候呢?这要延迟到该语句所在的函数即将执行结束的那一刻,无论结束执行的原因是什么。注意:
- 被延迟执行的是defer函数,而不是defer语句。简单的讲,函数预处理(我们稍后说明这种情况)
- 被延迟的函数不是在退出代码块的作用域时执行的,它只会在当前函数和方法返回之前被调用。(一定是当前函数)
- 在同一个函数中,defer函数调用的执行顺序与它们分别所属的defer语句的出现顺序(更严谨地说,是执行顺序)完全相反。
2.1. 底层原理
Defer的底层原理非常非常的复杂,我们这里只做一些简单描述和调用逻辑说明,具体的过程可以参看最后的连接或者直接阅读源码。
2.1.1. 数据结构说明
//源代码在 go/src/runtime/runtime2.go 861行
type _defer struct {
siz int32 //参数和返回结果的内存大小
started bool
heap bool
openDefer bool //是否开放编码优化
sp uintptr //栈指针计数器
pc uintptr //程序计数器
fn *funcval //实际延时的函数体 可以是空值
_panic *_panic //触发defer 的panic 可以是空的
link *_defer //指向下一个defer
//在开放编码优化的情况下,会使用到这两个字段
fd unsafe.Pointer
varp uintptr
//在堆栈模式下,会用到这个字段
framepc uintptr
}
通过观察,可以确认defer 仍然是一个链表结构,会通过link串起来;直观的反映了defer 优化过程:
- v1.13 以前的版本,采用堆上分配,性能较差
- v1.13 引入栈上分配,增加效率
- v1.14 引入了开放编码,进一步提高效率
2.1.2. 调用逻辑说明
这一部分的逻辑非常复杂,建议看下 GO语言设计实现,写的很清楚,配合源码使用。
问题引申,堆上分配和栈上分配的区别?GO 语言逃逸分析看内存分配
2.2. 一些现象与说明
func main(){
a := 1
b := 2
defer calc(a, calc(a,b,"0"),"1")
a = 0
defer calc(a, calc(a,b,"3"),"2")
}
func calc(x,y int,s string) int{
fmt.Println(s)
fmt.Println(x,y,x+y)
return x+y
}
返回的结果:
0
1 2 3
3
0 2 2
2
0 2 2
1
1 3 4
重要
这里考察两个点:
- Defer是栈调用,后写的先执行(先入后出)
- Defer的函数调用语句会在父函数调用后执行,但是用到的参数会在当时就执行得出(预计算)
这个现象就充分解释了一开始说的关于defer的知识点。关于第二点说的详细一下:
调用 runtime.deferproc 函数创建新的延迟调用时就会立刻拷贝函数的参数,函数的参数不会等到真正执行时计算
3. 一些问题
3.1. Go 的异常处理与 try…catch…finally 的区别
这里直接引用 郝林的回复:
这是两种完全不同的异常处理机制。Go语言的异常处理机制是两层的,defer和recover可以处理意外的的异常,而error接口及相关体系处理可预期的异常。Go语言把不同种类的异常完全区别对待,我觉得这是一个进步。
另外,defer机制能够处理的远不止异常,还有很多资源回收的任务可以用到它。defer机制和goroutine机制一样,是一种很有效果的创新。
我认为defer机制正是建立在goroutine机制之上的。因为每个函数都有可能成为go函数,所以必须要把异常处理做到函数级别。可以看到,defer机制和error机制都是以函数为边界的。前者在函数级别上阻止会导致非正常控制流的意外异常外溢,而后者在函数级别上用正常的控制流向外传递可预期异常。
不要说什么先驱,什么旧例,世界在进步,技术更是在猛进。不要把思维固化在某门或某些编程语言上。每种能够流行起来的语言都会有自己独有的、已经验证的语法、风格和哲学。
另外这里要再次提到一句谚语:
提示
Errors are values
3.2. 能不能在defer中触发Panic
答案是当然可以了,如果理解了上边的原理的话,就能详细解释了,panic是链状的,后触发的panic会添加到最前端,循环调用defer的时候就会处理最前面的那个panic(可以理解为后发先至)。出现的现象就是,defer外的panic会被里面的替换掉。同样的,你甚至可以在defer中调用defer!
func main(){
defer func() {
if p := recover(); p != nil {
fmt.Printf("panic: %s\n", p) }
}()
defer func() {
//defer func() {
//if p := recover(); p != nil {
// fmt.Printf("panic: %s\n", p) }
//}()
panic("panic in defer")
}()
panic("panic in main")
}