详解恐慌和恢复原理

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

函数调用的退出阶段

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

退出阶段有时候也被称为返回阶段。

一个函数调用可能通过三种途径进入它的退出阶段:
  1. 此调用正常返回;
  2. 当此调用中产生了一个恐慌;
  3. runtime.Goexit函数在此调用中被调用并且退出完毕。
比如,在下面这段代码中,
import (
	"fmt"
	"runtime"
)

func f0() int {
	var x = 1
	defer fmt.Println("正常退出:", x)
	x++
	return x
}

func f1() {
	var x = 1
	defer fmt.Println("正常退出:", x)
	x++
}

func f2() {
	var x, y = 1, 0
	defer fmt.Println("因恐慌而退出:", x)
	x = x / y // 将产生一个恐慌
	x++       // 执行不到
}

func f3() int {
	x := 1
	defer fmt.Println("因Goexit调用而退出:", x)
	x++
	runtime.Goexit()
	return x+x // 执行不到
}

顺便说一下,一般runtime.Goexit()函数不希望在主协程中调用。

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

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

我们已经了解到恐慌是可以被恢复的。 但是,Goexit信号是不能被取消的。

在任何一个给定时刻,一个函数调用最多只能和一个未恢复的恐慌相关联。 如果一个调用正和一个未恢复的恐慌相关联,则 比如,在下面这个例子中,最终被恢复的恐慌是恐慌3。它是最后一个和main函数调用相关联的恐慌。
package main

import "fmt"

func main() {
	defer func() {
		fmt.Println(recover()) // 3
	}()
	
	defer panic(3) // 将替换恐慌2
	defer panic(2) // 将替换恐慌1
	defer panic(1) // 将替换恐慌0
	panic(0)
}

因为Goexit信号不可被取消,争论一个函数调用是否最多只能和一个Goexit信号相关联是没有意义和没有必要的。

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

所以,当一个协程完成完毕后,此协程中最多只有一个尚未被恢复的恐慌。 如果一个协程带着一个尚未被恢复的恐慌退出完毕,则这将使整个程序崩溃,此恐慌信息将在程序崩溃的时候被打印出来。

在一个函数调用被执行的起始时刻,此调用将没有任何恐慌和Goexit信号和它相关联,这个事实和此函数调用的外层调用是否已经进入退出阶段无关。 当然,在此函数调用的执行过程中,恐慌可能产生,runtime.Goexit函数也可能被调用,因此恐慌和Goexit信号以后可能和此调用相关联起来。

下面这个例子程序在运行时将崩溃,因为新开辟的协程在退出完毕时仍带有一个未被恢复的恐慌。
package main

func main() {
	// 新开辟一个协程。
	go func() {
		// 一个匿名函数调用。
		// 当它退出完毕时,恐慌2将传播到此新协程的入口
		// 调用中,并且替换掉恐慌0。恐慌2永不会被恢复。
		defer func() {
			// 上一个例子中已经解释过了:恐慌2将替换恐慌1.
			defer panic(2)
			
			// 当此匿名函数调用退出完毕后,恐慌1将传播到刚
			// 提到的外层匿名函数调用中并与之关联起来。
			func () {
				panic(1)
				// 在恐慌1产生后,此新开辟的协程中将共存
				// 两个未被恢复的恐慌。其中一个(恐慌0)
				// 和此协程的入口函数调用相关联;另一个
				// (恐慌1)和当前这个匿名调用相关联。
			}()
		}()
		panic(0)
	}()
	
	select{}
}
此程序的输出(当使用标准编译器1.23版本编译):
panic: 0
	panic: 1
	panic: 2

...

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

类似地,当一个和Goexit信号相关联的内层函数调用退出完毕后,此Goexit信号也将传播到外层函数调用中,并和外层函数调用相关联起来。 如果外层函数调用尚未进入退出阶段,则其将立即进入。

当一个Goexit信号和一个函数调用相关联起来的时候,如果此函数调用正在和一个未被恢复的恐慌相关联着,则此恐慌将被恢复。 比如下面这个程序将正常退出并打印出<nil>,因为恐慌bye被Goexit信号恢复了。
package main

import (
	"fmt"
	"runtime"
)

func f() {
	defer func() {
		fmt.Println(recover())
	}()

	// 此调用产生的Goexit信号恢复之前产生的恐慌。
	defer runtime.Goexit()
	panic("bye")
}

func main() {
	go f()
	
	for runtime.NumGoroutine() > 1 {
		runtime.Gosched()
	}
}

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

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

func main() {
	defer func() {
		defer func() {
			recover() // 空操作
		}()
	}()
	defer func() {
		func() {
			recover() // 空操作
		}()
	}()
	func() {
		defer func() {
			recover() // 空操作
		}()
	}()
	func() {
		defer recover() // 空操作
	}()
	func() {
		recover() // 空操作
	}()
	recover()       // 空操作
	defer recover() // 空操作
	panic("bye")
}

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

func main() {
	defer func() {
		recover() // 将恢复恐慌"byte"
	}()

	panic("bye")
}

那么为什么本节中的第一个例子中的所有recover调用都不起作用呢? 让我们先看看当前版本的Go白皮书是怎么说的:
在下面的情况下,recover函数调用的返回值为nil
  • 传递给相应panic函数调用的实参为nil;
  • 当前协程并没有处于恐慌状态;
  • recover函数并未直接在一个延迟函数调用中调用。

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

本节中的第一个例子中的大多recover调用要么符合Go白皮书中描述的第二种情况,要么符合第三种情况,除了第一个recover调用。 是的,当前版本的白皮书中的描述并不准确。第三种情况应该更精确地描述为:

在本节中的第一个例子中,期望被恢复的恐慌和main函数调用相关联。 第一个recover调用确实被一个延迟函数调用所直接调用,但是此延迟函数调用并没有被main函数直接调用。 这就是为什么此recover调用是一个空操作的原因。

事实上,当前版本的白皮书也没有解释清楚为什么下面这个例子中的第二个recover调用(按照代码行顺序)没有起作用。 此调用本期待用来恢复恐慌1。
// 此程序将带着未被恢复的恐慌1而崩溃退出。
package main

func demo() {
	defer func() {
		defer func() {
			recover() // 此调用将恢复恐慌2
		}()

		defer recover() // 空操作

		panic(2)
	}()
	panic(1)
}

func main() {
	demo()
}

当前版本的白皮书没提到的一点是:每个recover调用都试图恢复当前协程中最新产生的且尚未恢复的恐慌。 当然,如果这个假设中的最新产生的且尚未恢复的恐慌不存在,则此recover调用是一个空操作。

Go运行时认为上例中的第二个recover调用试图恢复最新产生的尚未恢复的恐慌,即恐慌2。 而此时和恐慌2相关联的函数调用为此第二个recover调用的直接调用者,即外层的延迟函数调用。 此第二个recover调用并没有被外层的延迟函数调用所直接调用的某个延迟函数调用所调用; 相反,它直接被外层的延迟函数调用所调用。这就是为什么此第二个recover调用是一个空操作的原因。

总结

好了,到此我们可以对哪些recover调用会起作用做一个简短的描述:
一个recover调用只有在它的直接外层调用(即recover调用的父调用)是一个延迟调用,并且此延迟调用(即父调用)的直接外层调用(即recover调用的爷调用)和当前协程中最新产生并且尚未恢复的恐慌相关联时才起作用。 一个有效的recover调用将最新产生并且尚未恢复的恐慌和与此恐慌相关联的函数调用(即爷调用)剥离开来,并且返回当初传递给产生此恐慌的panic函数调用的参数。

目录↡

Go101.org网站内容包括Go编程各种相关知识(比如Go基础、Go优化、Go细节、Go实战、Go测验、Go工具等)。后续将不断有新的内容加入。敬请收藏关注期待。

本丛书微信公众号(联系方式一)名称为"Go 101"。二维码在网站首页。此公众号将时不时地发表一些Go语言相关的原创短文。各位如果感兴趣,可以搜索关注一下。

《Go语言101》系列丛书项目目前托管在Github上(联系方式二)。欢迎各位在此项目中通过提交bug和PR的方式来改进完善《Go语言101》丛书中的各篇文章。我们可以在项目目录下运行go run .来浏览和确认各种改动。

本书的twitter帐号为@Golang_101(联系方式三)。玩推的Go友可以适当关注。

你或许对本书作者老貘开发的一些App感兴趣。

The English version of this book is here.
赞赏
(《Go语言101》系列丛书由老貘从2016年7月开始编写。目前此系列丛书仍在不断改进和增容中。你的赞赏是本系列丛书和此Go101.org网站不断增容和维护的动力。)

目录: