Menu

Golang defer:执行顺序、参数求值与常见陷阱

defer 安排一个调用在外层函数返回时执行。了解后进先出的顺序、参数何时求值、关闭文件和释放互斥锁、循环中的 defer,以及修改命名返回值。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

defer 的作用

defer 把一个函数调用压入一个列表。外层函数返回时,这个列表按相反的顺序执行。

输出:

start
end
deferred 3
deferred 2
deferred 1

执行顺序是后进先出,就像栈一样。这与资源的嵌套方式一致:如果你先打开 A 再打开 B,通常希望先关闭 B 再关闭 A。

把清理代码写在获取资源的旁边

defer 的主要用途是在获取资源的下一行就写好清理代码,这样无论从哪条路径返回都不会忘记清理。

注意顺序:先检查错误,再 defer。如果 os.Open 失败了,f 就是 nil,在检查之前 defer f.Close() 会对一个 nil 的 *os.File 调用 Close(它返回一个你永远看不到的错误,还会误导读代码的人)。

同样的写法也适用于锁:

mu.Lock()
defer mu.Unlock()

即使两者之间的代码发生 panic,互斥锁也仍然会被释放。

参数会立即求值

被延迟的函数及其参数在 defer 语句执行时就会求值。只有调用本身会等待。

输出:

x is now 2
deferred closure reads: 2
deferred with argument: 1

fmt.Println("...", x) 在 defer 那一行就捕获了值 1。闭包没有参数;它在最终运行时才读取 x。根据你想记录的内容选择合适的形式。

这同样适用于方法接收者。defer t.Stop() 会立即对 t 求值,所以之后重新给 t 赋值,也不会改变被停止的是哪个值。

有一个常见的计时技巧正是有意利用了这条规则:

func handle() {
	defer trace("handle")() // trace runs now, the returned func runs at exit
	// ...
}

trace("handle") 会被立即调用(它可以打印“进入”并记录开始时间),而被 defer 的是它返回的那个函数。

循环中的 defer

延迟调用在函数返回时执行,而不是在每次循环迭代结束时执行。在遍历大量文件的循环中,这会让每个文件都一直打开到函数结束。

for _, path := range paths {
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close() // all files stay open until the function returns
	process(f)
}

路径有成千上万个时,这会耗尽文件描述符。把循环体移到一个单独的函数中,让每个 defer 在每次迭代时执行:

每次调用 processFile 都会在打开下一个文件之前关闭自己的文件。如果觉得单独写一个命名的辅助函数太麻烦,可以用一个就地调用的函数字面量(func() { ... }()),效果一样。

修改返回值

延迟的闭包在 return 语句给返回值赋值之后才运行,它可以在调用方看到之前修改命名返回值。

这段代码打印 10 和 save failed: disk full。如果返回值没有命名,延迟函数照样可以运行,但无法改变返回的内容。

捕获 Close 返回的错误

defer f.Close() 会丢弃 Close 返回的错误。对于只读的文件,这没有问题。对于写入过的文件,Close 可能会报告最后一次刷新失败,所以这个错误很重要。命名返回值可以让你保留它:

func writeReport(path string, data []byte) (err error) {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer func() {
		if cerr := f.Close(); cerr != nil && err == nil {
			err = cerr
		}
	}()
	_, err = f.Write(data)
	return err
}

如果想同时报告两个错误,errors.Join(err, f.Close()) 是更简短的写法。

defer、panic 与 recover

panic 展开调用栈时,延迟调用会执行。这是 recover 唯一能起作用的地方,服务器也正是靠它避免一个有问题的请求让整个进程崩溃。详细内容见 panic 和 recover。

程序通过 os.Exit 或 log.Fatal 退出时,延迟调用不会执行。如果 main defer 了清理工作,然后调用 os.Exit(1),清理就会被跳过。

开销

从 Go 1.14 开始,大多数 defer 都由编译器以开放编码(open-coded)的方式实现,开销只有几纳秒。每次释放互斥锁、关闭文件都使用 defer 是常规写法。循环中的 defer 是例外:它们无法开放编码,会退回到较慢的实现方式,这也是把循环体移到函数中的又一个理由。

常见错误

  • 在检查错误之前 defer。 先检查 Open 返回的 err,再 defer Close。
  • 以为循环中的清理会在每次迭代时执行。 defer 在函数退出时才执行。
  • 以为延迟调用的参数能看到之后的修改。 参数在 defer 那一行就固定了。要在退出时读取,请使用闭包。
  • 依赖 defer 配合 os.Exit。 它永远不会执行。

常见问题

Go 中的 defer 有什么作用?

defer f() 安排 f() 在外层函数返回时执行,无论函数是正常返回、通过提前的 return 返回,还是因 panic 退出。它用来把清理工作(关闭文件、释放互斥锁)写在获取资源的代码旁边。

Go 中延迟调用按什么顺序执行?

后进先出。最后被 defer 的调用最先执行。defer fmt.Println(1); defer fmt.Println(2) 会先打印 2,再打印 1。

延迟函数的参数什么时候求值?

立即求值,也就是在 defer 语句执行时,而不是在调用真正运行时。x := 1; defer fmt.Println(x); x = 2 打印的是 1。要读取退出时的值,请 defer 一个闭包:defer func() { fmt.Println(x) }()。

发生 panic 或调用 os.Exit 时,defer 会执行吗?

panic 展开调用栈时,延迟调用会执行,这也是 recover 能在其中起作用的原因。程序调用 os.Exit(或者调用它的 log.Fatal)时,延迟调用不会执行;main 返回时,其他 goroutine 中的延迟调用也不会执行。

Coddy 编程语言插图

和 Coddy 一起学编程

开始