Go语言 详解panic/recover原理 - 也解释了什么是"函数退出阶段"

2023-02-16 17:38 更新

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

函数调用的退出阶段

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

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

一个函数调用可能通过三种途径进入它的退出阶段:

  1. 此调用正常返回;
  2. 当此调用中产生了一个恐慌;
  3. 当​runtime.Goexit​函数在此调用中被调用并且退出完毕。

比如,在下面这段代码中,

  • 函数f0或者f1的一个调用将在它正常返回后进入它的退出阶段;
  • 函数f2的一个调用将在“被零除”恐慌产生之后进入它的退出阶段;
  • 函数f3的一个调用将在其中的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.19版本编译):

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调用。 是的,当前版本的白皮书中的描述并不准确。第三种情况应该更精确地描述为:

  • 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函数调用的参数。


以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号