Go語言 詳解panic/recover原理 - 也解釋了什么是"函數(shù)退出階段"

2023-02-16 17:38 更新

恐慌和恢復(fù)原理已經(jīng)在前面的文章中介紹過了。 一些恐慌和恢復(fù)用例也在上一篇文章中得到了展示。 本文將詳細(xì)解釋一下恐慌和恢復(fù)原理。函數(shù)調(diào)用的退出階段也將被一并詳細(xì)解釋。

函數(shù)調(diào)用的退出階段

在Go中,一個函數(shù)調(diào)用在其退出完畢之前可能將經(jīng)歷一個退出階段。 在此退出階段,所有在執(zhí)行此函數(shù)調(diào)用期間被推入延遲調(diào)用隊(duì)列的延遲函數(shù)調(diào)用將按照它們的推入順序的逆序被執(zhí)行。 當(dāng)這些延遲函數(shù)調(diào)用都退出完畢之后,此函數(shù)調(diào)用的退出階段也就結(jié)束了,或者說此函數(shù)調(diào)用也退出完畢了,

退出階段有時候也被稱為返回階段。

一個函數(shù)調(diào)用可能通過三種途徑進(jìn)入它的退出階段:

  1. 此調(diào)用正常返回;
  2. 當(dāng)此調(diào)用中產(chǎn)生了一個恐慌;
  3. 當(dāng)?runtime.Goexit?函數(shù)在此調(diào)用中被調(diào)用并且退出完畢。

比如,在下面這段代碼中,

  • 函數(shù)f0或者f1的一個調(diào)用將在它正常返回后進(jìn)入它的退出階段;
  • 函數(shù)f2的一個調(diào)用將在“被零除”恐慌產(chǎn)生之后進(jìn)入它的退出階段;
  • 函數(shù)f3的一個調(diào)用將在其中的runtime.Goexit函數(shù)調(diào)用退出完畢之后進(jìn)入它的退出階段。
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 // 將產(chǎn)生一個恐慌
	x++       // 執(zhí)行不到
}

func f3() int {
	x := 1
	defer fmt.Println("因Goexit調(diào)用而退出:", x)
	x++
	runtime.Goexit()
	return x+x // 執(zhí)行不到
}

順便說一下,一般runtime.Goexit()函數(shù)不希望在主協(xié)程中調(diào)用。

函數(shù)調(diào)用關(guān)聯(lián)恐慌和Goexit信號

當(dāng)一個函數(shù)調(diào)用中直接產(chǎn)生了一個恐慌的時候,我們可以認(rèn)為此(尚未被恢復(fù)的)恐慌將和此函數(shù)調(diào)用相關(guān)聯(lián)起來。 類似地,當(dāng)一個函數(shù)調(diào)用直接調(diào)用了runtime.Goexit函數(shù),則runtime.Goexit函數(shù)返回完畢之后,我們可以認(rèn)為一個Goexit信號將和此函數(shù)調(diào)用相關(guān)聯(lián)起來。 按照上一節(jié)中的解釋,當(dāng)一個恐慌或者一個Goexit信號和一個函數(shù)調(diào)用相關(guān)聯(lián)之后,此函數(shù)調(diào)用將立即進(jìn)入它的退出階段。

我們已經(jīng)了解到恐慌是可以被恢復(fù)的。 但是,Goexit信號是不能被取消的。

在任何一個給定時刻,一個函數(shù)調(diào)用最多只能和一個未恢復(fù)的恐慌相關(guān)聯(lián)。 如果一個調(diào)用正和一個未恢復(fù)的恐慌相關(guān)聯(lián),則

  • 在此恐慌被恢復(fù)之后,此調(diào)用將不再和任何恐慌相關(guān)聯(lián)。
  • 當(dāng)在此函數(shù)調(diào)用中產(chǎn)生了一個新的恐慌,此新恐慌將替換原來的未被恢復(fù)的恐慌做為和此函數(shù)調(diào)用相關(guān)聯(lián)的恐慌。

比如,在下面這個例子中,最終被恢復(fù)的恐慌是恐慌3。它是最后一個和main函數(shù)調(diào)用相關(guān)聯(lián)的恐慌。

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)
}

因?yàn)镚oexit信號不可被取消,爭論一個函數(shù)調(diào)用是否最多只能和一個Goexit信號相關(guān)聯(lián)是沒有意義和沒有必要的。

在某個時刻,一個協(xié)程中可能共存多個未被恢復(fù)的恐慌,盡管這在實(shí)際編程中并不常見。 每個未被恢復(fù)的恐慌和此協(xié)程的調(diào)用堆棧中的一個尚未退出的函數(shù)調(diào)用相關(guān)聯(lián)。 當(dāng)仍和一個未被恢復(fù)的恐慌相關(guān)聯(lián)的一個內(nèi)層函數(shù)調(diào)用退出完畢之后,此未被恢復(fù)的恐慌將傳播到調(diào)用此內(nèi)層函數(shù)調(diào)用的外層函數(shù)調(diào)用中。 這和在此外層函數(shù)調(diào)用中直接產(chǎn)生一個新的恐慌的效果是一樣的。也就是說,

  • 如果此外層函數(shù)已經(jīng)和一個未被恢復(fù)的舊恐慌相關(guān)聯(lián),則傳播出來的新恐慌將替換此舊恐慌并和此外層函數(shù)調(diào)用相關(guān)聯(lián)起來。 對于這種情形,此外層函數(shù)調(diào)用肯定已經(jīng)進(jìn)入了它的退出階段(剛提及的內(nèi)層函數(shù)肯定就是被延遲調(diào)用的),這時延遲調(diào)用隊(duì)列中的下一個延遲調(diào)用將被執(zhí)行。
  • 如果此外層函數(shù)尚未和一個未被恢復(fù)的舊恐慌相關(guān)聯(lián),則傳播出來的恐慌將和此外層函數(shù)調(diào)用相關(guān)聯(lián)起來。 對于這種情形,如果此外層函數(shù)調(diào)用尚未進(jìn)入它的退出階段,則它將立即進(jìn)入。

所以,當(dāng)一個協(xié)程完成完畢后,此協(xié)程中最多只有一個尚未被恢復(fù)的恐慌。 如果一個協(xié)程帶著一個尚未被恢復(fù)的恐慌退出完畢,則這將使整個程序崩潰,此恐慌信息將在程序崩潰的時候被打印出來。

在一個函數(shù)調(diào)用被執(zhí)行的起始時刻,此調(diào)用將沒有任何恐慌和Goexit信號和它相關(guān)聯(lián),這個事實(shí)和此函數(shù)調(diào)用的外層調(diào)用是否已經(jīng)進(jìn)入退出階段無關(guān)。 當(dāng)然,在此函數(shù)調(diào)用的執(zhí)行過程中,恐慌可能產(chǎn)生,runtime.Goexit函數(shù)也可能被調(diào)用,因此恐慌和Goexit信號以后可能和此調(diào)用相關(guān)聯(lián)起來。

下面這個例子程序在運(yùn)行時將崩潰,因?yàn)樾麻_辟的協(xié)程在退出完畢時仍帶有一個未被恢復(fù)的恐慌。

package main

func main() {
	// 新開辟一個協(xié)程。
	go func() {
		// 一個匿名函數(shù)調(diào)用。
		// 當(dāng)它退出完畢時,恐慌2將傳播到此新協(xié)程的入口
		// 調(diào)用中,并且替換掉恐慌0??只?永不會被恢復(fù)。
		defer func() {
			// 上一個例子中已經(jīng)解釋過了:恐慌2將替換恐慌1.
			defer panic(2)
			
			// 當(dāng)此匿名函數(shù)調(diào)用退出完畢后,恐慌1將傳播到剛
			// 提到的外層匿名函數(shù)調(diào)用中并與之關(guān)聯(lián)起來。
			func () {
				panic(1)
				// 在恐慌1產(chǎn)生后,此新開辟的協(xié)程中將共存
				// 兩個未被恢復(fù)的恐慌。其中一個(恐慌0)
				// 和此協(xié)程的入口函數(shù)調(diào)用相關(guān)聯(lián);另一個
				// (恐慌1)和當(dāng)前這個匿名調(diào)用相關(guān)聯(lián)。
			}()
		}()
		panic(0)
	}()
	
	select{}
}

此程序的輸出(當(dāng)使用標(biāo)準(zhǔn)編譯器1.19版本編譯):

panic: 0
	panic: 1
	panic: 2

...

此輸出的格式并非很完美,它容易讓一些程序員誤認(rèn)為恐慌0是最終未被恢復(fù)的恐慌。而事實(shí)上,恐慌2才是最終未被恢復(fù)的恐慌。

類似地,當(dāng)一個和Goexit信號相關(guān)聯(lián)的內(nèi)層函數(shù)調(diào)用退出完畢后,此Goexit信號也將傳播到外層函數(shù)調(diào)用中,并和外層函數(shù)調(diào)用相關(guān)聯(lián)起來。 如果外層函數(shù)調(diào)用尚未進(jìn)入退出階段,則其將立即進(jìn)入。

當(dāng)一個Goexit信號和一個函數(shù)調(diào)用相關(guān)聯(lián)起來的時候,如果此函數(shù)調(diào)用正在和一個未被恢復(fù)的恐慌相關(guān)聯(lián)著,則此恐慌將被恢復(fù)。 比如下面這個程序?qū)⒄M顺霾⒋蛴〕?code><nil>,因?yàn)榭只?code>bye被Goexit信號恢復(fù)了。

package main

import (
	"fmt"
	"runtime"
)

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

	// 此調(diào)用產(chǎn)生的Goexit信號恢復(fù)之前產(chǎn)生的恐慌。
	defer runtime.Goexit()
	panic("bye")
}

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

一些recover調(diào)用相當(dāng)于空操作(No-Op)

內(nèi)置recover函數(shù)必須在合適的位置調(diào)用才能發(fā)揮作用;否則,它的調(diào)用相當(dāng)于空操作。 比如,在下面這個程序中,沒有一個recover函數(shù)調(diào)用恢復(fù)了恐慌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")
}

我們已經(jīng)知道下面這個recover調(diào)用是有作用的。

package main

func main() {
	defer func() {
		recover() // 將恢復(fù)恐慌"byte"
	}()

	panic("bye")
}

那么為什么本節(jié)中的第一個例子中的所有recover調(diào)用都不起作用呢? 讓我們先看看當(dāng)前版本的Go白皮書是怎么說的:

在下面的情況下,recover函數(shù)調(diào)用的返回值為nil

  • 傳遞給相應(yīng)panic函數(shù)調(diào)用的實(shí)參為nil;
  • 當(dāng)前協(xié)程并沒有處于恐慌狀態(tài);
  • recover函數(shù)并未直接在一個延遲函數(shù)調(diào)用中調(diào)用。

上一篇文章中提供了一個第一種情況的例子

本節(jié)中的第一個例子中的大多recover調(diào)用要么符合Go白皮書中描述的第二種情況,要么符合第三種情況,除了第一個recover調(diào)用。 是的,當(dāng)前版本的白皮書中的描述并不準(zhǔn)確。第三種情況應(yīng)該更精確地描述為:

  • ?recover?函數(shù)并未直接被一個延遲函數(shù)調(diào)用所直接調(diào)用, 或者它直接被一個延遲函數(shù)調(diào)用所直接調(diào)用但是此延遲調(diào)用沒有被和期望被恢復(fù)的恐慌相關(guān)聯(lián)的函數(shù)調(diào)用所直接調(diào)用。

在本節(jié)中的第一個例子中,期望被恢復(fù)的恐慌和main函數(shù)調(diào)用相關(guān)聯(lián)。 第一個recover調(diào)用確實(shí)被一個延遲函數(shù)調(diào)用所直接調(diào)用,但是此延遲函數(shù)調(diào)用并沒有被main函數(shù)直接調(diào)用。 這就是為什么此recover調(diào)用是一個空操作的原因。

事實(shí)上,當(dāng)前版本的白皮書也沒有解釋清楚為什么下面這個例子中的第二個recover調(diào)用(按照代碼行順序)沒有起作用。 此調(diào)用本期待用來恢復(fù)恐慌1。

// 此程序?qū)е幢换謴?fù)的恐慌1而崩潰退出。
package main

func demo() {
	defer func() {
		defer func() {
			recover() // 此調(diào)用將恢復(fù)恐慌2
		}()

		defer recover() // 空操作

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

func main() {
	demo()
}

當(dāng)前版本的白皮書沒提到的一點(diǎn)是:每個recover調(diào)用都試圖恢復(fù)當(dāng)前協(xié)程中最新產(chǎn)生的且尚未恢復(fù)的恐慌。 當(dāng)然,如果這個假設(shè)中的最新產(chǎn)生的且尚未恢復(fù)的恐慌不存在,則此recover調(diào)用是一個空操作。

Go運(yùn)行時認(rèn)為上例中的第二個recover調(diào)用試圖恢復(fù)最新產(chǎn)生的尚未恢復(fù)的恐慌,即恐慌2。 而此時和恐慌2相關(guān)聯(lián)的函數(shù)調(diào)用為此第二個recover調(diào)用的直接調(diào)用者,即外層的延遲函數(shù)調(diào)用。 此第二個recover調(diào)用并沒有被外層的延遲函數(shù)調(diào)用所直接調(diào)用的某個延遲函數(shù)調(diào)用所調(diào)用; 相反,它直接被外層的延遲函數(shù)調(diào)用所調(diào)用。這就是為什么此第二個recover調(diào)用是一個空操作的原因。

總結(jié)

好了,到此我們可以對哪些recover調(diào)用會起作用做一個簡短的描述:

一個recover調(diào)用只有在它的直接外層調(diào)用(即recover調(diào)用的父調(diào)用)是一個延遲調(diào)用,并且此延遲調(diào)用(即父調(diào)用)的直接外層調(diào)用(即recover調(diào)用的爺調(diào)用)和當(dāng)前協(xié)程中最新產(chǎn)生并且尚未恢復(fù)的恐慌相關(guān)聯(lián)時才起作用。 一個有效的recover調(diào)用將最新產(chǎn)生并且尚未恢復(fù)的恐慌和與此恐慌相關(guān)聯(lián)的函數(shù)調(diào)用(即爺調(diào)用)剝離開來,并且返回當(dāng)初傳遞給產(chǎn)生此恐慌的panic函數(shù)調(diào)用的參數(shù)。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號