详解恐慌和恢复原理

恐慌和恢复原理已经在前面的文章中介绍过了。一些恐慌和恢复用例也在上一篇文章中得到了展示。本文将详细解释一下恐慌和恢复原理。函数调用的退出阶段也将被一并详细解释。

函数调用的退出阶段

在Go中,一个函数调用在其退出完毕之前可能将经历一个退出阶段。在此退出阶段,所有在执行此函数调用期间被推入延迟调用堆栈的延迟函数调用将按照它们的推入顺序的逆序被执行。当这些延迟函数调用都退出完毕之后,此函数调用的退出阶段也就结束了,或者说此函数调用也退出完毕了, 一个函数调用可能通过三种途径进入它的退出阶段:

  • 此调用正常返回;
  • 当此调用中产生了一个恐慌;
  • runtime.Goexit函数在此调用中被调用并且退出完毕。比如,在下面这段代码中,
  • 函数f0或者f1的一个调用将在它正常返回后进入它的退出阶段;
  • 函数f2的一个调用将在“被零除”恐慌产生之后进入它的退出阶段;
  • 函数f3的一个调用将在其中的runtime.Goexit函数调用退出完毕之后进入它的退出阶段。
  1. import (
  2. "fmt"
  3. "runtime"
  4. )
  5. func f0() int {
  6. var x = 1
  7. defer fmt.Println("正常退出:", x)
  8. x++
  9. return x
  10. }
  11. func f1() {
  12. var x = 1
  13. defer fmt.Println("正常退出:", x)
  14. x++
  15. }
  16. func f2() {
  17. var x, y = 1, 0
  18. defer fmt.Println("因恐慌而退出:", x)
  19. x = x / y // 将产生一个恐慌
  20. x++ // 执行不到
  21. }
  22. func f3() int {
  23. x := 1
  24. defer fmt.Println("因Goexit调用而退出:", x)
  25. x++
  26. runtime.Goexit()
  27. return x+x // 执行不到
  28. }

函数调用关联恐慌和Goexit信号

当一个函数调用中直接产生了一个恐慌的时候,我们可以认为此(尚未被恢复的)恐慌将和此函数调用相关联起来。类似地,当一个函数调用直接调用了runtime.Goexit函数,则runtime.Goexit函数返回完毕之后,我们可以认为一个Goexit信号将和此函数调用相关联起来。恐慌和Goexit信号互相独立于对方,两者互不干扰。按照上一节中的解释,当一个恐慌或者一个Goexit信号和一个函数调用相关联之后,此函数调用将立即进入它的退出阶段。

我们已经了解到恐慌是可以被恢复的。但是,Goexit信号是不能被取消的。 在任何一个给定时刻,一个函数调用最多只能和一个未恢复的恐慌相关联。如果一个调用正和一个未恢复的恐慌相关联,则

  • 在此恐慌被恢复之后,此调用将不再和任何恐慌相关联。
  • 当在此函数调用中产生了一个新的恐慌,此新恐慌将替换原来的未被恢复的恐慌做为和此函数调用相关联的恐慌。比如,在下面这个例子中,最总被恢复的恐慌是恐慌3。它是最后一个和main函数调用相关联的恐慌。
  1. package main
  2. import "fmt"
  3. func main() {
  4. defer func() {
  5. fmt.Println(recover()) // 3
  6. }()
  7. defer panic(3) // 将替换恐慌2
  8. defer panic(2) // 将替换恐慌1
  9. defer panic(1) // 将替换恐慌0
  10. panic(0)
  11. }

因为Goexit信号不可被取消,争论一个函数调用是否最多只能和一个Goexit信号相关联是没有意义和没有必要的。 在某个时刻,一个协程中可能共存多个未被恢复的恐慌,尽管这在实际编程中并不常见。每个未被恢复的恐慌和此协程的调用堆栈中的一个尚未退出的函数调用相关联。当仍和一个未被恢复的恐慌相关联的一个内层函数调用退出完毕之后,此未被恢复的恐慌将传播到调用此内层函数调用的外层函数调用中。这和在此外层函数调用中直接产生一个新的恐慌的效果是一样的。也就是说,

  • 如果此外层函数已经和一个未被恢复的旧恐慌相关联,则传播出来的新恐慌将替换此旧恐慌并和此外层函数调用相关联起来。对于这种情形,此外层函数调用肯定已经进入了它的退出阶段(刚提及的内层函数肯定就是被延迟调用的),这时延迟调用堆栈中的下一个延迟调用将被执行。
  • 如果此外层函数尚未和一个未被恢复的旧恐慌相关联,则传播出来的恐慌将和此外层函数调用相关联起来。对于这种情形,如果此外层函数调用尚未进入它的退出阶段,则它将立即进入。 所以,当一个协程完成完毕后,此协程中最多只有一个尚未被恢复的恐慌。如果一个协程带着一个尚未被恢复的恐慌退出完毕,则这将使整个程序崩溃,此恐慌信息将在程序崩溃的时候被打印出来。

在一个函数调用被执行的起始时刻,此调用将没有任何恐慌和Goexit信号和它相关联,这个事实和此函数调用的外层调用是否已经进入退出阶段无关。当然,在此函数调用的执行过程中,恐慌可能产生,runtime.Goexit函数也可能被调用,因此恐慌和Goexit信号以后可能和此调用相关联起来。 下面这个例子程序在运行时将崩溃,因为新开辟的协程在退出完毕时仍带有一个未被恢复的恐慌。

  1. package main
  2. func main() {
  3. // 新开辟一个协程。
  4. go func() {
  5. // 一个匿名函数调用。
  6. // 当它退出完毕时,恐慌2将传播到此新协程的入口
  7. // 调用中,并且替换掉恐慌0。恐慌2永不会被恢复。
  8. defer func() {
  9. // 上一个例子中已经解释过了:恐慌2将替换恐慌1.
  10. defer panic(2)
  11. // 当此匿名函数调用退出完毕后,恐慌1将传播到刚
  12. // 提到的外层匿名函数调用中并与之关联起来。
  13. func () {
  14. panic(1)
  15. // 在恐慌1产生后,此新开辟的协程中将共存
  16. // 两个未被恢复的恐慌。其中一个(恐慌0)
  17. // 和此协程的入口函数调用相关联;另一个
  18. // (恐慌1)和当前这个匿名调用相关联。
  19. }()
  20. }()
  21. panic(0)
  22. }()
  23. select{}
  24. }

此程序的输出(当使用标准编译器1.13版本编译):

  1. panic: 0
  2. panic: 1
  3. panic: 2
  4. goroutine 5 [running]:
  5. ...

此输出的格式并非很完美,它容易让一些程序员误认为恐慌0是最终未被恢复的恐慌。而事实上,恐慌2才是最终未被恢复的恐慌。

类似地,当一个和Goexit信号相关联的内层函数调用退出完毕后,此Goexit信号也将传播到外层函数调用中,并和外层函数调用相关联起来。如果外层函数调用尚未进入退出阶段,则其将立即进入。 上面提到了恐慌和Goexit信号时互不干扰的。换句话说,一个尚未被恢复的恐慌不会导致一个Goexit信号被取消;一个Goexit信号也不会压制遮挡一个尚未被恢复的恐慌。不过目前最新版本的官方两个编译器(标准编译器v1.13和gccgo v8.0)都并未正确实现此规则。比如,下面这个程序本应崩溃退出,但是并没有(使用最新版本的标准编译器和gccgo编译器编译)。

  1. package main
  2. import "runtime"
  3. func main() {
  4. c := make(chan struct{})
  5. go func() {
  6. defer close(c)
  7. // 此调用产生的Goexit信号将压制先前
  8. // 产生的恐慌"bye",但是不应该如此。
  9. defer runtime.Goexit()
  10. panic("bye")
  11. }()
  12. <-c
  13. }

下面是另一个两个编译器都未正确实现互不干扰规则的例子。此例子程序运行时应该马上退出,但是事实上它却永不退出(使用最新版本的标准编译器和gccgo编译器编译)。

  1. package main
  2. import "runtime"
  3. func f() {
  4. defer func() {
  5. recover()
  6. }()
  7. defer panic("将取消Goexit信号但是不应该这样")
  8. runtime.Goexit()
  9. }
  10. func main() {
  11. c := make(chan struct{})
  12. go func() {
  13. defer close(c)
  14. f()
  15. for {
  16. runtime.Gosched()
  17. }
  18. }()
  19. <-c
  20. }

标准编译器和gccgo编译器将在以后的版本中修复问题

一些recover调用相当于空操作(No-Op)

内置recover函数必须在合适的位置调用才能发挥作用;否则,它的调用相当于空操作。比如,在下面这个程序中,没有一个recover函数调用恢复了恐慌bye

  1. package main
  2. func main() {
  3. defer func() {
  4. defer func() {
  5. recover() // 空操作
  6. }()
  7. }()
  8. defer func() {
  9. func() {
  10. recover() // 空操作
  11. }()
  12. }()
  13. func() {
  14. defer func() {
  15. recover() // 空操作
  16. }()
  17. }()
  18. func() {
  19. defer recover() // 空操作
  20. }()
  21. func() {
  22. recover() // 空操作
  23. }()
  24. recover() // 空操作
  25. defer recover() // 空操作
  26. panic("bye")
  27. }

我们已经知道下面这个recover调用是有作用的。

  1. package main
  2. func main() {
  3. defer func() {
  4. recover() // 将恢复恐慌"byte"
  5. }()
  6. panic("bye")
  7. }

那么为什么本节中的第一个例子中的所有recover调用都不起作用呢?让我们先看看当前版本的Go白皮书是怎么说的: 在下面的情况下,recover函数调用的返回值为nil

  • 传递给相应panic函数调用的实参为nil;
  • 当前协程并没有处于恐慌状态;
  • recover函数并未直接在一个延迟函数调用中调用。

上一篇文章中提供了一个第一种情况的例子

本节中的第一个例子中的大多recover调用要么符合Go白皮书中描述的第二种情况,要么符合第三种情况,除了第一个recover调用。是的,当前版本的白皮书中的描述并不准确。此描述正在被改进中。 事实上,当前版本的白皮书也没有解释清楚为什么下面这个例子中的第二个recover调用没有起作用。此调用本用来恢复恐慌1。

  1. // 此程序将带着未被恢复的恐慌1而退出。
  2. package main
  3. func demo() {
  4. defer func() {
  5. defer func() {
  6. recover() // 此调用将恢复恐慌2
  7. }()
  8. defer recover() // 空操作
  9. panic(2)
  10. }()
  11. panic(1)
  12. }
  13. func main() {
  14. demo()
  15. }

当前版本的白皮书没提到的一点是:在任何时刻,一个协程中只有最新产生的恐慌才能够被恢复。换句话说,每个recover调用都被视为一个恢复当前协程中最新产生并且尚未恢复的恐慌的试图。这就是为什么上例中的第二个recover调用不起作用的原因。 好了,到此我们可以对哪些recover调用会起作用做一个简单地解释: 一个recover调用只有在它的直接外层调用(即recover调用的父调用)是一个延迟调用,并且此延迟调用(即父调用)的直接外层调用(即recover调用的爷调用)和当前协程中最新产生并且尚未恢复的恐慌相关联时才起作用。一个有效的recover调用将最新产生并且尚未恢复的恐慌和与此恐慌相关联的函数调用(即爷调用)剥离开来,并且返回当初传递给产生此恐慌的panic函数调用的参数。

Go语言101项目目前同时托管在GithubGitlab上。欢迎各位在这两个项目中通过提交bug和PR的方式来改进完善Go语言101中的各篇文章。

本书微信公众号名称为"Go 101"。每个工作日此公众号将尽量发表一篇和Go语言相关的原创短文。各位如果感兴趣,可以搜索关注一下。

赞赏