极客时间已完结课程限时免费阅读

21 | panic函数、recover函数以及defer语句 (上)

21 | panic函数、recover函数以及defer语句 (上)-极客时间

21 | panic函数、recover函数以及defer语句 (上)

讲述:黄洲君

时长07:45大小3.55M

我在上两篇文章中,详细地讲述了 Go 语言中的错误处理,并从两个视角为你总结了错误类型、错误值的处理技巧和设计方式。
在本篇,我要给你展示 Go 语言的另外一种错误处理方式。不过,严格来说,它处理的不是错误,而是异常,并且是一种在我们意料之外的程序异常。

前导知识:运行时恐慌 panic

这种程序异常被叫做 panic,我把它翻译为运行时恐慌。其中的“恐慌”二字是由 panic 直译过来的,而之所以前面又加上了“运行时”三个字,是因为这种异常只会在程序运行的时候被抛出来。
我们举个具体的例子来看看。
比如说,一个 Go 程序里有一个切片,它的长度是 5,也就是说该切片中的元素值的索引分别为01234,但是,我在程序里却想通过索引5访问其中的元素值,显而易见,这样的访问是不正确的。
Go 程序,确切地说是程序内嵌的 Go 语言运行时系统,会在执行到这行代码的时候抛出一个“index out of range”的 panic,用以提示你索引越界了。
当然了,这不仅仅是个提示。当 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。在这个 panic 中,包含了一个runtime.Error接口类型的值。runtime.Error接口内嵌了error接口,并做了一点点扩展,runtime包中有不少它的实现类型。
实际上,此详情中的“panic:”右边的内容,正是这个 panic 包含的runtime.Error类型值的字符串表示形式。
此外,panic 详情中,一般还会包含与它的引发原因有关的 goroutine 的代码执行信息。正如前述详情中的“goroutine 1 [running]”,它表示有一个 ID 为1的 goroutine 在此 panic 被引发的时候正在运行。
注意,这里的 ID 其实并不重要,因为它只是 Go 语言运行时系统内部给予的一个 goroutine 编号,我们在程序中是无法获取和更改的。
我们再看下一行,“main.main()”表明了这个 goroutine 包装的go函数就是命令源码文件中的那个main函数,也就是说这里的 goroutine 正是主 goroutine。再下面的一行,指出的就是这个 goroutine 中的哪一行代码在此 panic 被引发时正在执行。
这包含了此行代码在其所属的源码文件中的行数,以及这个源码文件的绝对路径。这一行最后的+0x3d代表的是:此行代码相对于其所属函数的入口程序计数偏移量。不过,一般情况下它的用处并不大。
最后,“exit status 2”表明我的这个程序是以退出状态码2结束运行的。在大多数操作系统中,只要退出状态码不是0,都意味着程序运行的非正常结束。在 Go 语言中,因 panic 导致程序结束运行的退出状态码一般都会是2
综上所述,我们从上边的这个 panic 详情可以看出,作为此 panic 的引发根源的代码处于 demo47.go 文件中的第 5 行,同时被包含在main包(也就是命令源码文件所在的代码包)的main函数中。
那么,我的第一个问题也随之而来了。我今天的问题是:从 panic 被引发到程序终止运行的大致过程是什么?
这道题的典型回答是这样的。
我们先说一个大致的过程:某个函数中的某行代码有意或无意地引发了一个 panic。这时,初始的 panic 详情会被建立起来,并且该程序的控制权会立即从此行代码转移至调用其所属函数的那行代码上,也就是调用栈中的上一级。
这也意味着,此行代码所属函数的执行随即终止。紧接着,控制权并不会在此有片刻的停留,它又会立即转移至再上一级的调用代码处。控制权如此一级一级地沿着调用栈的反方向传播至顶端,也就是我们编写的最外层函数那里。
这里的最外层函数指的是go函数,对于主 goroutine 来说就是main函数。但是控制权也不会停留在那里,而是被 Go 语言运行时系统收回。
随后,程序崩溃并终止运行,承载程序这次运行的进程也会随之死亡并消失。与此同时,在这个控制权传播的过程中,panic 详情会被逐渐地积累和完善,并会在程序终止之前被打印出来。

问题解析

panic 可能是我们在无意间(或者说一不小心)引发的,如前文所述的索引越界。这类 panic 是真正的、在我们意料之外的程序异常。不过,除此之外,我们还是可以有意地引发 panic。
Go 语言的内建函数panic是专门用于引发 panic 的。panic函数使程序开发者可以在程序运行期间报告异常。
注意,这与从函数返回错误值的意义是完全不同的。当我们的函数返回一个非nil的错误值时,函数的调用方有权选择不处理,并且不处理的后果往往是不致命的。
这里的“不致命”的意思是,不至于使程序无法提供任何功能(也可以说僵死)或者直接崩溃并终止运行(也就是真死)。
但是,当一个 panic 发生时,如果我们不施加任何保护措施,那么导致的直接后果就是程序崩溃,就像前面描述的那样,这显然是致命的。
为了更清楚地展示答案中描述的过程,我编写了 demo48.go 文件。你可以先查看一下其中的代码,再试着运行它,并体会它打印的内容所代表的含义。
我在这里再提示一点。panic 详情会在控制权传播的过程中,被逐渐地积累和完善,并且,控制权会一级一级地沿着调用栈的反方向传播至顶端。
因此,在针对某个 goroutine 的代码执行信息中,调用栈底端的信息会先出现,然后是上一级调用的信息,以此类推,最后才是此调用栈顶端的信息。
比如,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 详情应该是我们的必备技能,这在调试 Go 程序或者为 Go 程序排查错误的时候非常重要。

总结

最近的两篇文章,我们是围绕着 panic 函数、recover 函数以及 defer 语句进行的。今天我主要讲了 panic 函数。这个函数是专门被用来引发 panic 的。panic 也可以被称为运行时恐慌,它是一种只能在程序运行期间抛出的程序异常。
Go 语言的运行时系统可能会在程序出现严重错误时自动地抛出 panic,我们在需要时也可以通过调用panic函数引发 panic。但不论怎样,如果不加以处理,panic 就会导致程序崩溃并终止运行。

思考题

一个函数怎样才能把 panic 转化为error类型值,并将其作为函数的结果值返回给调用方?
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 18

提建议

上一篇
20 | 错误处理 (下)
下一篇
22 | panic函数、recover函数以及defer语句(下)
unpreview
 写留言

精选留言(15)

  • 江山如画
    2018-10-08
    一个函数如果要把 panic 转化为error类型值,并将其结果返回给调用方,可以考虑把 defer 语句封装到一个匿名函数之中,下面是实验的一个例子,所用函数是一个除法函数,当除数为0的时候会抛出 panic并捕获。 func divide(a, b int) (res int, err error) { func() { defer func() { if rec := recover(); rec != nil { err = fmt.Errorf("%s", rec) } }() res = a / b }() return } func main() { res, err := divide(1, 0) fmt.Println(res, err) // 0 runtime error: integer divide by zero res, err = divide(2, 1) fmt.Println(res, err) // 2 <nil> }
    展开
    共 2 条评论
    37
  • 2020-03-06
    老师,你好,我有一个疑问,请教一下,谢谢~ Go在设计的时候没有设计try...catch...finally这样的方式来捕获异常。 我在网上查很多人用panic、defer和recover组合来实现异常的捕获,甚至很多都将这个二次封装之后作为一个库来进行使用。 我的疑问是,从Go的设计角度为什么要这么做?是出于什么样的目的,还是他俩之间有什么优劣? 非常感谢~,烦请解答。
    展开

    作者回复: Go的错误处理机制是由两个部分组成的,panic代表着特殊的(或者说意外的)错误,error代表着普通的错误。与try-catch不同,error并不是打断正常的控制流程的执行。单单这一点来讲,就已经是非常好的进步了。相比之下,panic会打断正常的控制流程。从这一点上看,panic与try-catch很像。 说到这里,你可能也意识到了,try-catch是一套行为单一的错误处理机制,而Go语言的(error+panic)把错误处理机制在代码级别分为了两个部分。 这样的好处是,倒逼开发者去思考,什么时候应该返回普通的错误,什么时候应该抛出意外的错误。这种思考在设计一个程序的错误体系的时候是非常重要的,关系到程序运行的稳定性。 至于缺点,error容易被滥用,导致程序中到处是 if err != nil 的代码。但是我们要清楚的是,这往往是程序设计上的问题,而不是语言层面的问题。如果不当心,try-catch照样会被弄的满屏都是。而且try-catch还有一个颗粒度和数量的问题(与临界区的颗粒度和数量问题类似)。 总之,我个人认为Go语言的错误处理机制是一种创新和进步。不过,由于容易被滥用,Go语言团队不是还在近几年一直在考虑更好的解决方案吗。我也很期待他们新的设计。

    共 8 条评论
    17
  • Bang
    2018-09-28
    先使用go中的类似try catch这样的语句,将异常捕获的异常转为相应的错误error就可以了
    13
  • 唐丹
    2018-09-28
    郝大,你好,我在golang 8中通过recover处理panic时发现,必须在引发panic的当前协程就处理掉,否则待其传递到父协程直至main方法中,都不能通过recover成功处理掉了,程序会因此结束。请问这样设计的原因是什么?那么协程是通过panic中记录的协程id来区分是不是在当前协程引发的panic的吗?另外,这样的话,我们应用程序中每一个通过go新起的协程都应该在开始的地方recover,否则即使父协程有recover也不能阻止程序因为一个意外的panic而挂掉?盼望解答,谢谢🙏
    展开

    作者回复: 只要在调用栈路径上就都可以处理,如果你用了defer语句和recover函数等正确处理方式还是不行的话,就要看看这个panic是不是不了恢复的。一些runtime抛出来的panic是不可恢复的,因为问题很严重必须整改代码才行。

    共 5 条评论
    11
  • 沐夜星光
    2020-05-21
    “控制权如此一级一级地沿着调用栈的反方向传播至顶端,也就是我们编写的最外层函数那里”。最外层函数是go函数,也就说当panic触发,通过其所在的goroutine,将控制权转移给运行时系统时,是不一定经过main函数的吗?另外老师能不能讲讲,go是怎么回收一个进程的,怎么处理运行中的goroutine以及涉及的资源。

    作者回复: 这会经过main函数,异步调用也是调用。main函数就是主goroutine的go函数。 go回收进程?这没什么稀奇的啊,就是调用操作系统的底层API,你可以查看 runtime.main 函数了解相关过程,或者查看 runtime.exit 函数了解进程退出时调用的API。 在一个goroutine中的go函数执行完之后,这个goroutine会转为_Gidle状态,其中的所有关键字段都会被重置,它的栈内存也会被释放。最后,它会被放入自由G列表。你可以通过查看 runtime.goexit0 了解到。

    共 2 条评论
    4
  • 冰激凌的眼泪
    2018-10-02
    panic时,会捕获异常及异常上下文(函数名+文件行) 类似看作有一个异常上下文列表,始于异常触发处,沿着函数调用逆向展开,每一级append自己的异常上下文,直至goroutine入口函数,最终被runtime捕获 最终异常信息被打印,异常上下文列表被顺序打印,程序退出
    3
  • 🐻
    2019-03-03
    https://gist.github.com/bwangelme/9ce1c606ba9f69c72f82722adf1402e1
    2
  • A 凡
    2018-10-24
    之前自己学习时候的一些模糊点更加清晰了,支持!
    2
  • MClink
    2021-03-25
    初学go都会吐槽说没有 try catch , 应该不止我一个

    作者回复: 每种语言的风格都不同啊,习惯习惯就好了(注意力可以更多的放在“怎样高效构建优秀软件上” ,语法的一些特点不属于关键问题 :-) ),而且后续Go在这方面会有改善。

    共 4 条评论
    1
  • MClink
    2022-07-07
    这个看起来比较简单 func main() { res, err := divide(1, 0) if err != nil { fmt.Println(err) } fmt.Println(res) } func divide(a, b int) (res int, err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("omg, panic ! err:%v", r) return } }() res = a / b return }
    展开
  • Harlan
    2021-08-12
    go这种满屏幕都是 判断 err!=nil 这种没有意义的代码 代码侵入性极强 也不优雅
  • Geek_37a441
    2020-12-15
    老师,你好,我想问下,panic触发的时机,比如指令执行过程中,在什么时候会调用到相关的panic,比如数组越界是什么时候调用runtime.panicIndex,是有个额外的线程不断检测有异常了吗?

    作者回复: 当然不是了,是执行程序的程序碰到严重错误就抛出来,属于Go运行时系统的职责范围。

    共 4 条评论
  • 传说中的成大大
    2020-03-25
    今日总结 今天主要是讲了panic运行时恐慌,一般发生在运行时 同样也可以自己调用内置的panic来主动引起恐慌 一般用来自己调试程序异常等等 主要掌握panic的执行过程 首先从某一行引起了panic 然后返回到其调用函数 这样一级一级的返回 直到最顶层也即main函数中 最后把控制权交给了go运行时状态,最后程序奔溃 进程退出 panic的信息一般也在这个返回过程中不断的完善 panic: runtime error: index out of range //错误信息 //以下是调用堆栈 goroutine 1 [running]: main.caller2() /home/ubuntu/geek_go/Golang_Puzzlers/src/puzzlers/article19/q1/demo48.go:22 +0x91 main.caller1() /home/ubuntu/geek_go/Golang_Puzzlers/src/puzzlers/article19/q1/demo48.go:15 +0x66 main.main() /home/ubuntu/geek_go/Golang_Puzzlers/src/puzzlers/article19/q1/demo48.go:9 +0x66 exit status 2 关于思考题 我也先想了一下 我们捕获异常并将错误信息封装到一个error当中最后返回这个err 后来又去翻了解答 好像就是这个 样子的
    展开
  • 虢國技醬
    2019-01-21
    打卡
  • melody_future
    2018-12-25
    panic、recover 有点像try、、catch。这样应该会好理解很多