Go 语言中的错误处理不仅仅只有 if err != nil
,defer
、panic
和 recover
这三个相对来说不不如 if err != nil
有名气的控制流语句,也与错误处理息息相关。本文就来讲解下这三者在 Go 语言中的应用。
Defer
defer
是一个 Go 中的关键字,通常用于简化执行各种清理操作的函数。defer
后跟一个函数(或方法)调用,该函数(或方法)的执行会被推迟到外层函数返回的那一刻,即函数(或方法)要么遇到了 return
,要么遇到了 panic
。
语法
defer
功能使用语法如下:
1 | defer Expression |
其中 Expression
必须是函数或方法的调用。
defer
使用示例如下:
1 | func f() { |
执行示例代码,得到输出如下:
1 | $ go run main.go |
根据输出可以发现,被 defer
修饰的 fmt.Println("deferred in f")
调用并没有立即执行,而是先执行了 fmt.Println("calling f")
,然后才会执行 defer
修饰的函数调用语句。
执行顺序
一个函数中可以写多个 defer
语句:
1 | func f() { |
执行示例代码,得到输出如下:
1 | $ go run main.go |
被 defer
修饰的函数调用,在外层函数返回后按后进先出顺序执行,即 Last In First Out(LIFO
)。
不仅如此,defer
可以写在任意位置,并且还可以嵌套,即在被 defer
修饰的函数中再次使用 defer
。
示例如下:
1 | func f() { |
执行示例代码,得到输出如下:
1 | $ go run main.go |
这个输出结果符合你的预期吗?
先看外层函数 f
的代码逻辑,有两个 defer
语句,无论位置在哪,defer
都会使函数调用延迟执行,所以先输出了 1
、5
、7
。
然后根据 LIFO
原则,先执行第 2 个 defer
语句所修饰的函数调用,所以输出 6
。
接着执行第 1 个 defer
语句所修饰的函数调用,其内部同样会按顺序执行没有被 defer
语句修饰的代码,所以先输出 2
、4
,然后执行 defer
语句所修饰的函数调用,输出 3
。
读写函数返回值
有时候,我们可以使用 defer
语句来读取或修改函数的返回值。
有如下示例,试图在 defer
中修改函数的返回值:
1 | func f() int { |
执行示例代码,得到输出如下:
1 | $ go run main.go |
看来没有成功。
函数使用具名返回值再来看看:
1 | func f() (r int) { |
执行示例代码,得到输出如下:
1 | $ go run main.go |
这次成功了。
如果改成这样呢:
1 | func f() (r int) { |
现在,返回值直接写成了 2
,而非变量 r
。
执行示例代码,得到输出如下:
1 | $ go run main.go |
这次返回值依然修改成功了。
前面几个示例,其实都算使用了闭包。因为被 defer
修饰的函数内部都引用了外部变量 r
。
我们再看一个不使用闭包的示例:
1 | func f() (r int) { |
执行示例代码,得到输出如下:
1 | $ go run main.go |
这次返回值没有修改成功,并且被 defer
修饰的函数内部读到的 r
值为 0
,并不是前面示例中的 2
。
也就是说,实际上虽然被 defer
修饰的函数调用会延迟执行,但是我们传递给函数的参数,会被立即求值。
我们接着看下面这个示例:
1 | func f() (r int) { |
执行示例代码,得到输出如下:
1 | $ go run main.go |
当代码执行到 return x
时,r
值也会被赋值为 2
,这没什么好解释的。
然后在 defer
所修饰的函数内部,我们只修改了 x
变量,这对返回结果 r
没有影响。
把函数返回值类型改成指针试试呢:
1 | func f() (r *int) { |
执行示例代码,得到输出如下:
1 | $ go run main.go |
这次返回值又成功被修改了。
看到这里,你是不是对 defer
语句的效果有点懵,没关系,我们再来梳理下 defer
执行时机。
defer
语句的行为其实是可预测的,我们可以记住这三条规则:
- 在计算
defer
语句时,将立即计算被defer
修饰的函数参数。 - 被
defer
修饰的函数,在外层函数返回后按后进先出的顺序(LIFO
)执行。 - 延迟函数可以读取或赋值给外层函数的具名返回值。
现在,你再翻回去重新看看上面的几个示例程序,是不是都能理解了呢?
释放资源
defer
还常被用来释放资源,比如关闭文件对象。
这里有个示例程序,可以将一个文件内容复制到另外一个文件中:
1 | func CopyFile(dstName, srcName string) (written int64, err error) { |
不过这个程序存在 bug
,如果 os.Create
执行失败,函数返回后 src
并没有被关闭。
而这种场景刚好适用 defer
,示例如下:
1 | func CopyFile(dstName, srcName string) (written int64, err error) { |
此时如果 os.Create
执行失败,函数返回后 defer src.Close()
将会被执行,文件资源得以释放。
切记,不要在 if err != nil
之前调用 defer
释放资源,这很可能会触发 panic
。
1 | src, err := os.Open(srcName) |
因为,如果调用 os.Open
报错,src
值将为 nil
,而 nil.Close()
会触发 panic
,导致程序意外终止而退出。
此外,在处理释放资源的情况,你可能写出如下代码:
1 | type fakeFile struct { |
执行示例代码,得到输出如下:
1 | $ go run main.go |
可以发现,在函数 processFile
中,因为 f
被重复赋值,导致 f
变量的值最终是 f2
,所以 f2
会被关闭两次,f1
并没有被关闭。
还记得我们前面讲过的规则吗:在计算 defer
语句时,将立即计算被 defer
修饰的函数参数。
所以,我们可以在 defer
处让变量 f
先被计算出来:
1 | func processFile1() { |
这样就解决了问题。
当然,更简单的方式是我们压根就不要使用同一个变量来表示不同的文件对象:
1 | func processFile2() { |
不过,有时候在在 for
循环中,就是会出现 f
被重复赋值的情况,在 for
循环中使用 defer
语句,我们可能还会踩到类似的坑,所以你一定要小心。
WithClose
文章读到这里,想必你也看出来了,defer
功能正是对标了 Python 中的 try...finally
或者 with
语句的效果。
Python 的 with
语法非常优雅,如何使用 defer
实现近似效果呢?
你可以在我的另一篇文章《在 Go 中如何实现类似 Python 中的 with 上下文管理器》中找到答案。
篇幅所限,我就不在这里再废话连篇的讲一遍了。
如果你想用下面这种单独的代码块作用域来实现:
1 | func f() { |
很遗憾的告诉你,这并不能达到想要的效果,你可以思考后再点击我的另一篇文章来对比下你我二人的实现是否相同。
结构体方法是否使用指针接收者
当结构体方法使用指针作为接收者时,也要小心。
示例如下:
1 | type User struct { |
执行示例代码,得到输出如下:
1 | $ go run main.go |
User.Name
方法接收者为结构体,在 defer
中被调用,最终输出结果为初始 name
值 user1
。
User.PointName
方法接收者为指针,在 defer
中被调用,最终输出结果为修改后的 name
值 user2
。
可见,defer
处不仅会计算函数参数,其实它会对其后面的表达式求值,并计算出最终将要执行的函数或方法。
也就是说,代码执行到 defer u.Name()
时,变量 u
的值就已经计算出来了,相当于“复制”了一个新的变量,后面再通过 u.name = "user2"
修改其属性,二者已经不是同一个变量了。
而代码执行到 defer u.PointName()
时,其实这里的 u
是指针类型,即使“复制”了一个新的变量,其内部保存的指针依然相等,所以可以被修改。
如果将代码修改成如下这样,执行结果又会怎样呢?
1 | func printUser() { |
这个就交给你自己去实验了。
当 defer 遇到 os.Exit
当 defer
遇到 os.Exit
时会怎样呢?
1 | func f() { |
执行示例代码,得到输出如下:
1 | $ go run main.go |
可见,当遇到 os.Exit
时,程序直接退出,defer
并不会被执行,这一点平时开发过程中要格外注意。
一个过时的面试题
前几年,有一个考察 defer
的面试题经常在网上出现:
1 | func f() { |
问执行 f
以后,输出什么?
既然会成为面试题,执行结果就肯定有猫腻。
如果你使用 Go 1.22 以前的版本执行示例代码,将得到如下结果:
1 | $ go run main.go |
而如果你使用 Go 1.22 及以后的版本执行示例代码,将得到如下结果:
1 | $ go run main.go |
这是由于,在 Go 1.22 以前,由 for
循环声明的变量只会被创建一次,并在每次迭代时更新。在 Go 1.22 中,循环的每次迭代都会创建新的变量,以避免意外的共享错误。
这在 Go 1.22 Release Notes 中有说明。
在旧版本的 Go 中要修复这个问题,只需要这样写即可:
1 | func f() { |
直接把 defer
放在外面,不要构成闭包。
又或者为 defer
函数增加参数:
1 | func f() { |
总之,解决方案就是不要出现闭包。
不要出现 defer nil 的情况
前文说过,defer
后面支持函数或方法的调用。
但是,如果计算 defer
后的表达式出现 nil
的情况,则会触发 panic
。
1 | func deferNil() { |
执行示例代码,得到输出如下:
1 | calling deferNil |
因为 nil
不可被调用。
至于到底什么是 panic
,咱们往下看。
Panic
在 Go 中,error
表示一个错误,错误通常会返给调用方,交由调用方来决定如何处理。而 panic
则表示一个无法挽回的异常,panic
会直接终止当前执行的控制流。
panic
是一个内置函数,它会停止程序的正常控制流并输出 panic
相关信息。
有两种方式可以触发 panic
,一种是非法操作导致运行时错误,比如访问数组索引越界,此时会触发运行时 panic
。另一种是主动调用 panic
函数。
当在函数 F
中调用了 panic
后,程序执行流程如下:
函数 F
调用 panic
时,F
的执行会被停止,接下来会执行 F
中调用 panic
之前的所有 defer
函数,然后 F
返回给调用者。
接着,对于 F
的调用方 G
的行为也类似于对 panic
的调用。
该过程继续向上返回,直到当前 goroutine
中的所有函数都返回,此时程序崩溃。
最后,你将在执行 Go 程序的控制台看到程序执行异常的堆栈信息。
使用
panic
使用示例如下:
1 | func f() { |
执行示例代码,得到输出如下:
1 | $ go run main.go |
可以发现,panic
会输出异常堆栈信息。
并且 1
和 defer 1
都被输出了,而 2
和 defer 2
没有输出,说明 panic
调用之后的代码不会执行,但它不影响 panic
之前 defer
函数的执行。
此外,如果你足够细心,还可以发现 panic
后程序的退出码为 2
。
子 Goroutine 中 panic
如果在子 goroutine
中发生 panic
,也会导致主 goroutine
立即退出:
1 | func g() { |
执行示例代码,程序并不会等待 10s 后才退出,而是立即 panic
并退出,得到输出如下:
1 | $ go run main.go |
panic 和 os.Exit
虽然 panic
和 os.Exit
都能使程序终止并退出,但它们有着显著的区别,尤其在触发时的行为和对程序流程的影响上。
panic
用于在程序中出现异常情况时引发一个运行时错误,通常会导致程序崩溃(除非被 recover
恢复)。当触发 panic
时,defer
语句仍然会执行。panic
还会打印详细的堆栈信息,显示引发错误的调用链。panic
退出状态码固定为 2
。
os.Exit
会立即终止程序,并返回指定的状态码给操作系统。当执行 os.Exit
时,defer
语句不会执行。os.Exit
直接通知操作系统退出程序,它不会返回给调用者,也不会引发运行时堆栈追踪,所以也就不会打印堆栈信息。os.Exit
可以设置程序退出状态码。
因为 panic
比较暴力,所以一般只建议在 main
函数中使用,比如应用的数据库初始化失败后直接 panic
,因为程序无法连接数据库,程序继续执行意义不大。而普通函数中推荐尽量返回 error
而不是直接 panic
。
不过 panic
也不是没有挽救的余地,recover
就是来恢复 panic
的。
Recover
recover
也是一个函数,用来从 panic
所导致的程序崩溃中恢复执行。
使用
recover
使用示例如下:
1 | func f() { |
执行示例代码,得到输出如下:
1 | $ go run main.go |
recover()
的调用捕获了 panic
触发的异常,并且程序正常退出。
recover
函数只在 defer
语句的上下文中才有效,直接调用的话,只会返回 nil
。
如下两种方式都是错误的用法:
1 | recover() |
可见,recover
必须与 defer
一同使用,来从 panic
中恢复程序。不过 panic
之后的代码依旧不会执行,recover()
调用后只会执行 defer
语句中的剩余代码。
下面这个例子将会捕获到 panic
,并且输出 panic
信息:
1 | func f() { |
执行示例代码,得到输出如下:
1 | $ go run main.go |
可以发现,recover
函数的返回值,正是 panic
函数的参数。
不要在 defer 中出现 panic
为了避免不必要的麻烦,defer
函数中最好不要有能够引起 panic
的代码。
正常来说,defer
用来释放资源,不会出现大量代码。如果 defer
函数中逻辑过多,则需要斟酌下有没有更优解。
如下示例将输出什么?
1 | func f() { |
执行示例代码,得到输出如下:
1 | $ go run main.go |
看来,defer
中的 panic("woah 1")
覆盖了程序正常控制流中的 panic("woah 2")
。
如果我们将代码顺序稍作修改:
1 | func f() { |
执行示例代码,得到输出如下:
1 | $ go run main.go |
看来,调用 recover
的 defer
应该放在函数的入口处,成为第一个 defer
。
recover 只能捕获当前 Goroutine 中的 panic
需要额外注意的一点是,recover
只会捕获当前 goroutine
所触发的 panic
。
示例如下:
1 | func f() { |
执行示例代码,得到输出如下:
1 | $ go run main.go |
子 goroutine
中触发的 panic
并没有被 recover
捕获。
所以,如果你认为代码中需要捕获 panic
时,就需要在每个 goroutine
中都执行 recover
。
将 panic 转换成 error 返回
有时候,我们可能需要将 panic
转换成 error
并返回,防止当前函数调用他人提供的不可控代码时出现意外的 panic
。
1 | func g(i int) (number int, err error) { |
执行示例代码,得到输出如下:
1 | $ go run main.go |
net/http 使用 recover 优雅处理 panic
我们在开发 HTTP Server 程序时,即使某个请求遇到了 panic
也不应该使整个程序退出。所以,就需要使用 recover
来处理 panic
。
来看一个使用 net/http
创建的 HTTP Server 程序示例:
1 | package main |
启动示例,程序会阻塞在这里等待请求进来:
1 | $ go run main.go |
使用 curl
命令分别对 HTTP Server 发送三次请求:
1 | $ curl localhost:8080 |
可以发现,在请求 /panic
路由时,HTTP Server 触发了 panic
并返回了空内容,然后第三个请求依然能够得到正确的响应。
可见 HTTP Server 并没有退出。
现在回去看一下执行 HTTP Server 的控制台日志:
1 | Starting server on :8080 |
panic
信息 url is error
被输出了,并且打印了堆栈信息。
不过这 HTTP Server 依然在运行,并能提供服务。
这其实就是在 net/http
中使用了 recover
来处理 panic
。
我们可以看下 http.Server.Serve
的源码:
1 | func (srv *Server) Serve(l net.Listener) error { |
可以发现,在 for
循环中,每接收到一个请求都会交给 go c.serve(connCtx)
开启一个新的 goroutine
来处理。
那么在 serve
方法中就一定会有 recover
语句:
1 | // Serve a new connection. |
果然,在 serve
方法源码中发现了 defer
+ recover
的组合。
并且这行代码:
1 | c.server.logf("http: panic serving %v: %v\n%s", c.remoteAddr, err, buf) |
可以在执行 HTTP Server 的控制台日志中得到印证:
1 | http: 2024/10/13 23:08:28 http: panic serving [::1]:50547: url is error |
panic(nil)
panic
函数签名如下:
1 | func panic(v any) |
既然 panic
参数是 any
类型,那么 nil
当然也可以作为参数。
可以写出 panic(nil)
程序示例代码如下:
1 | func f() { |
执行示例代码,得到输出如下:
1 | $ go run main.go |
这没什么问题。
但是在 Go 1.21 版本以前,执行上述代码,将得到如下结果:
1 | $ go run main.go |
你没看错,我也没写错误,这里什么都没输出。
在旧版本的 Go 中,panic(nil)
并不能被 recover
捕获,recover()
调用结果将返回 nil
。
你可以在 issues/25448 中找到关于此问题的讨论。
幸运的是,在 Go 1.21 发布时,这个问题得以解决。
不过,这就破坏了 Go 官方承诺的 Go1 兼容性保障。因此,Go 团队又提供了 GODEBUG=panicnil=1
标识来恢复旧版本中的 panic
行为。
使用方式如下:
1 | $ GODEBUG=panicnil=1 go run main.go |
其实,根据 panic
声明中的注释我们也能够观察到 Go 1.21 后 panic(nil)
行为有所改变:
1 | // Starting in Go 1.21, calling panic with a nil interface value or an |
panic
相关源码实现如下:
1 | // The implementation of the predeclared function panic. |
在没有指定 GODEBUG=panicnil=1
情况下,panic(nil)
调用等价于 panic(new(runtime.PanicNilError))
。
数据库事务
使用 defer
+ recover
来处理数据库事务,也是比较常用的做法。
这里有一个来自 GORM
官方文档中的 示例程序:
1 | type Animal struct { |
在函数最开始开启了一个事务,接着使用 defer
+ recover
来确保程序执行中间过程遇到 panic
时能够回滚事务。
程序执行过程中使用 tx.Create
创建了两条 Animal
数据,并且如果输出,都将回滚事务。
如果没有错误,最终调用 tx.Commit()
提交事务,并将其错误结果返回。
这个函数实现逻辑非常严谨,没什么问题。
但是这个示例代码写的过于啰嗦,还有优化的空间,可以写成这样:
1 | func CreateAnimals(db *gorm.DB) error { |
这里在 defer
中直接去掉了 recover
的判断,所以无论如何程序最终都会执行 tx.Rollback()
。
之所以可以这样写,是因为调用 tx.Commit()
时事务已经被提交成功,之后执行 tx.Rollback()
并不会影响已经提交事务。
这段代码看上去要简洁不少,不必在每次出现 error
时都想着调用 tx.Rollback()
回滚事务。
你可能认为这样写有损代码性能,但其实绝大多数场景下我们不需要担心。我更愿意用一点点可以忽略不计的性能损失,换来一段清晰的代码,毕竟可读性很重要。
panic 并不是都可以被 recover 捕获
最后,咱们再来看一个并发写 map
的场景,如果触发 panic
结果将会怎样?
示例如下:
1 | func f() { |
这里启动两个 goroutine
来并发的对 map
进行写操作,并且每个 goroutine
中都使用 defer
+ recover
来保证能够正常处理 panic
发生。
最后使用 select {}
阻塞主 goroutine
防止程序退出。
执行示例代码,得到输出如下:
1 | $ go run main.go |
然而程序还是输出 panic
信息 fatal error: concurrent map writes
并退出了。
但是根据输出信息,我们无法知道具体原因。
在 Go 1.19 Release Notes 中有提到,从 Go 1.19 版本开始程序遇到不可恢复的致命错误(例如并发写入 map
,或解锁未锁定的互斥锁)只会打印一个简化的堆栈信息,不包含运行时元数据。不过这可以通过将环境变量 GOTRACEBACK
被设置为 system
或 crash
来解决。
所以我们可以使用如下两种方式来输出更详细的堆栈信息:
1 | $ GOTRACEBACK=system go run main.go |
再次执行示例代码,得到输出如下:
1 | $ GOTRACEBACK=system go run main.go |
这里省略了大部分堆栈输出,只保留了重要部分。根据堆栈信息可以发现在 runtime/map_fast64.go:122
处发生了 panic
。
相关源码内容如下:
1 | func mapassign_fast64(t *maptype, h *hmap, key uint64) unsafe.Pointer { |
显然是第 122 行代码 fatal("concurrent map writes")
触发了 panic
,并且其参数内容 concurrent map writes
也正是输出结果。
fatal
函数源码如下:
1 | // fatal triggers a fatal error that dumps a stack trace and exits. |
fatal
内部调用了 fatalthrow
来触发 panic
。看来由 fatalthrow
所触发的 panic
无法被 recover
捕获。
我们开发时要切记:并发读写 map
触发 panic
,无法被 recover
捕获。
并发操作 map
一定要小心,这是一个比较危险的行为,在 Web 开发中,如果在某个接口 handler
方法中触发了 panic
,整个 http Server 会直接挂掉。
涉及并发操作 map
,我们应该使用 sync.Map
来代替:
1 | func f() { |
这个示例就不会 panic
了。
总结
本文对错误处理三剑客 defer
、panic
和 recover
进行了讲解梳理,虽然这三者并不是 error
,但它们与错误处理息息相关。
defer
可以推迟一个函数或方法的调用,通常用于简化执行各种清理操作的函数。
panic
是一个内置函数,它会停止程序的正常控制流并输出 panic
相关信息。相比于 error
,panic
更加暴力,谨慎使用。
recover
用来从 panic
所导致的程序崩溃中恢复执行,并且要与 defer
一起使用。
本文示例源码我都放在了 GitHub 中,欢迎点击查看。
希望此文能对你有所启发。
延伸阅读
- Go 1.19 Release Notes:https://go.dev/doc/go1.19#runtime
- Go 1.21 Release Notes:https://go.dev/doc/go1.21#language
- Go 1.22 Release Notes:https://go.dev/doc/go1.22#language
- Defer, Panic, and Recover:https://go.dev/blog/defer-panic-and-recover
- Defer statements:https://go.dev/ref/spec#Defer_statements
- Handling panics:https://go.dev/ref/spec#Handling_panics
- Understanding the Go “defer”:https://blog.devgenius.io/understanding-the-go-defer-3b6b66905e7e
- How to return a value in a Go function that panics?:https://stackoverflow.com/questions/33167282/how-to-return-a-value-in-a-go-function-that-panics
- issues/25448 spec: guarantee non-nil return value from recover:https://github.com/golang/go/issues/25448
- GORM 事务:https://gorm.io/zh_CN/docs/transactions.html
- 在 Go 中如何实现类似 Python 中的 with 上下文管理器:https://jianghushinian.cn/2023/06/23/how-to-implement-a-context-manager-similar-to-python-s-with-in-go/
- 本文 GitHub 示例代码:https://github.com/jianghushinian/blog-go-example/tree/main/error/defer-panic-recover
联系我
- 公众号:Go编程世界
- 微信:jianghushinian
- 邮箱:jianghushinian007@outlook.com
- 博客:https://jianghushinian.cn