从panic被引发到程序终止运行的大致过程是什么?
大致过程:
某个函数中的某行代码有意无意地引发了一个panic
。这时,初始的panic
详情会被建立起来,并且该程序的控制权会立即从从行代码转移至调用其所属函数的那行代码上,也就是调用栈中的上一级。
意味着,此行代码所属函数的执行随即终止。紧接着,控制权并不会在此有片刻的停留,它又会立即转移至再上一级的调用代码处。控制权如此一级一级地沿着调用栈的反方向转播至顶端,也就是我们编写的最外层函数那里。
这里的最外层函数指的是go
函数,对于主goroutine来说就是main函数。但是控制权也不会停留在那里,而是被Go语言运行时系统收回。
随后,程序崩溃并终止运行,承载程序这次运行的进程也会随之死亡而消失。与此同时,在这个控制权传播的过程中,-panic详情给会被逐渐地积累和完善,并会在程序终止之前被打印出来。
问题解析
panic
可能是我们在无意间(或者说一不小心)引发的,如上文所述的索引越界。这类panic
是真正的、在我们意料之外的程序异常。除此之外,我们还是可以有意地引发panic
。
Go语言的内建函数panic
是专门用于引发panic
的。panic
函数使程序开发者可以在程序运行期间报告异常。
注意,这与从函数返回错误值的意义是完全不同的。当我们的函数返回一个非nil的错误值时,函数的调用方有权选择不处理,并且不处理的后果往往是不致命的。
这里的“不致命”的意思是,不至于使程序无法提供任何功能(也可以说僵死)或者直接崩溃并终止运行(也就是真死)。
但是,当一个 panic 发生时,如果我们不施加任何保护措施,那么导致的直接后果就是程序崩溃,就像前面描述的那样,这显然是致命的。
panic
详情会在控制权传播的过程中,被逐渐地积累和完善,并且,控制权会一级一级地沿着调用栈的反方向传播至顶端。在针对某个 goroutine
的代码执行信息中,调用栈底端的信息会先出现,然后是上一级调用的信息,以此类推,最后才是此调用栈顶端的信息。
eg:
main
函数调用了caller1
函数,而caller1
函数又调用了caller2
函数,那么caller2
函数中代码的执行信息会先出现,然后是caller1
函数中代码的执行信息,最后才是main
函数的信息。
goroutine 1 [running]:
main.caller2()
/Users/haolin/GeekTime/Golang_Puzzlers/src/puzzlers/article19/q1/demo48.go:22 +0x91
main.caller1()
/Users/haolin/GeekTime/Golang_Puzzlers/src/puzzlers/article19/q1/demo48.go:15 +0x66
main.main()
/Users/haolin/GeekTime/Golang_Puzzlers/src/puzzlers/article19/q1/demo48.go:9 +0x66
exit status 2
怎样让panic包含一个值,以及应该让它包含什么样的值?
答案:其实很简单,在调用panic
函数时,把某个值作为参数传给该函数就可以了。由于panic
函数的唯一一个参数是空接口(也就是interface{})类型的,所以从语法上讲,它可以接受任何类型的值。
但是,我们最好传入error类型的错误值,或者其他的可以被有效序列化的值。这里的“有效序列化”指的是,可以更易读地去表示形式转换。
一旦程序异常了,我们就一定要把异常的相关信息记录下来,这通常都是记到程序日志里。
我们在为程序排查错误的时候,首先要做的就是查看和解读程序日志;而最常用也是最方便的日志记录方式,就是记下相关值的字符串表示形式。
所以,如果你觉得某个值有可能会被记到日志里,那么就应该为它关联String
方法。如果这个值是error
类型的,那么让它的Error
方法返回你为它定制的字符串表示形式就可以了。
怎样施加应对panic的保护措施,从而避免程序崩溃?
Go 语言的内建函数recover
专用于恢复 panic
,或者说平息运行时恐慌。recover
函数无需任何参数,并且会返回一个空接口类型的值。
如果用法正确,这个值实际上就是即将恢复的 panic
包含的值。并且,如果这个 panic
是因我们调用panic
函数而引发的,那么该值同时也会是我们此次调用panic
函数时,传入的参数值副本。
请注意,这里强调用法的正确。我们先来看看什么是不正确的用法。
package main
import (
"fmt"
"errors"
)
func main() {
fmt.Println("Enter function main.")
// 引发 panic。
panic(errors.New("something wrong"))
p := recover()
fmt.Printf("panic: %s\n", p)
fmt.Println("Exit function main.")
}
在上面这个main
函数中,我先通过调用panic
函数引发了一个 panic
,紧接着想通过调用recover
函数恢复这个 panic
。可结果呢?你一试便知,程序依然会崩溃,这个recover
函数调用并不会起到任何作用,甚至都没有机会执行。
还记得吗?我提到过 panic
一旦发生,控制权就会迅速地沿着调用栈的反方向传播。所以,在panic
函数调用之后的代码,根本就没有执行的机会。
那如果我把调用recover函数的代码提前呢? 也就是说,先调用recover函数,再调用panic函数会怎么样呢?
这显然也是不行的,因为,如果在我们调用recover函数时未发生 panic,那么该函数就不会做任何事情,并且只会返回一个nil。
那么,到底什么才是正确的recover函数用法呢?
defer
语句就是被用来延迟执行代码的。延迟到什么时候呢? 这要延迟到该语句所在的函数即将执行结束的那一刻,无论结束执行的原因是什么。
一个defer
语句总是由一个defer
关键字和一个调用表达式组成。这里存在一些限制,有一些调用表达式是不能出现在这里的,包括:针对 Go
语言内建函数的调用表达式,以及针对unsafe
包中的函数的调用表达式。
无论函数结束执行的原因是什么,其中的defer
函数调用都会在它即将结束执行的那一刻执行。即使导致它执行结束的原因是一个 panic
也会是这样。正因为如此,我们需要联用defer
语句和recover
函数调用,才能够恢复一个已经发生的 panic
。
修正后的代码
package main
import (
"fmt"
"errors"
)
func main() {
fmt.Println("Enter function main.")
defer func(){
fmt.Println("Enter defer function.")
if p := recover(); p != nil {
fmt.Printf("panic: %s\n", p)
}
fmt.Println("Exit defer function.")
}()
// 引发 panic。
panic(errors.New("something wrong"))
fmt.Println("Exit function main.")
}
这样,defer
函数中的recover
函数调用才会拦截,并恢复defer
语句所属的函数,及其调用的代码中发生的所有 panic
。
如果一个函数中有多条defer语句,那么那几个defer函数调用的执行顺序是怎样的?
答案:在同一个函数中,defer
函数调用的执行顺序与它们分别所属的defer
语句的出现顺序(更严谨地说,是执行顺序)完全相反。
当一个函数即将结束执行时,其中的写在最下边的defer函数调用会最先执行,其次是写在它上边、与它的距离最近的那个defer函数调用,以此类推,最上边的defer函数调用会最后一个执行。
原理:
在defer
语句每次执行的时候,Go
语言会把它携带的defer
函数及其参数值另行存储到一个队列中。
这个队列与该defer
语句所属的函数是对应的,并且,它是先进后出(FILO)
的,相当于一个栈。
在需要执行某个函数中的defer
函数调用的时候,Go
语言会先拿到对应的队列,然后从该队列中一个一个地取出defer
函数及其参数值,并逐个执行调用。
文章学习自郝林老师的《Go语言36讲》