1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package main
import "fmt"
func Triple(n int) (r int) {
defer func() {
r += n // 修改返回值
}()
return n + n // <=> r = n + n; return
}
func main() {
fmt.Println(Triple(5)) // 15
}

这个程序输出 15 是因为 defer 语句和命名返回值的结合使用。在 Go 中,defer 语句会在函数返回之前执行,而在这个例子中,它被用来修改函数的返回值。

分析 Triple 函数的执行过程:

  1. Triple 函数被调用时,n 的值是 5
  2. 函数有一个命名返回值 r。在 Go 中,如果函数有命名返回值,那么这个返回值在函数体开始时就已经被声明并初始化为零值(在这个例子中是 0)。
  3. return n + n 这行代码先执行,n5,因此 n + n 计算的结果是 10。这行代码把 10 赋值给了返回变量 r,然后准备返回。
  4. 然而,在实际返回之前,defer 声明的匿名函数被执行。这个匿名函数把 n 的值(5)加到了 r 上。因此,此时 r 变成了 10 + 5,即 15
  5. 最终返回的值是 15

这里的关键点在于 defer 语句会在 return 语句之后执行(即使在 return 语句之前被声明),但它仍然是在实际返回值被传递给调用方之前。由于返回值是命名的(r),defer 中的代码有机会修改它。

所以,当 Triple(5) 被调用时,函数中的返回值 r 被先设置为 10,然后 defer 语句执行,将 5 加到 r 上,最后返回 15。这就是为什么输出是 15


多个 panic 情况下

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

  • 如果此外层函数已经和一个未被恢复的旧恐慌相关联,则传播出来的新恐慌将替换此旧恐慌并和此外层函数调用相关联起来。 对于这种情形,此外层函数调用肯定已经进入了它的退出阶段(刚提及的内层函数肯定就是被延迟调用的),这时延迟调用队列中的下一个延迟调用将被执行。
  • 如果此外层函数尚未和一个未被恢复的旧恐慌相关联,则传播出来的恐慌将和此外层函数调用相关联起来。 对于这种情形,如果此外层函数调用尚未进入它的退出阶段,则它将立即进入。

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

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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.22版本编译):

1
2
3
4
5
panic: 0
	panic: 1
	panic: 2

...

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

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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()
	}
}

核心概念:

  • 恐慌的传播: 当一个内层函数调用发生恐慌且未被恢复时,恐慌会向外传播到调用它的外层函数。
  • 恐慌的替换: 如果外层函数已经存在一个未恢复的恐慌,新传播来的恐慌会替换掉它。
  • Goexit 信号的传播: 类似恐慌,Goexit 信号也会从内层函数向外传播。
  • Goexit 信号恢复恐慌: 当 Goexit 信号与一个函数关联时,如果该函数存在未恢复的恐慌,恐慌会被恢复。