面试官提问:
"Go 程序在什么情况下会发生 Panic?"
"Recover 能捕获所有 Panic 吗?比如并发场景下的?"
"能手写一个通用的 Panic 捕获中间件吗?"
大家好,我是公号「左诗右码」作者 Alex。
在 Go 语言的设计哲学中,错误(Error)和异常(Panic)是两个完全不同的概念。
- Error:是业务逻辑的一部分,是预期的,应该被处理(如文件打开失败、网络超时)。
- Panic:是严重的程序崩溃,是不可预期的(如空指针引用、数组越界)。
面试官问 Panic,通常不是想听你列举报错类型,而是想考察你对程序稳定性和并发安全的理解。今天,我们就来聊聊如何优雅地处理“炸弹”。
1. 触发 Panic 的常见场景(避雷指南)
除了手动调用 panic(),以下是开发中最容易踩的雷:
-
空指针引用:
var p *int; *p = 1,或者访问一个 nil 结构体指针的成员。 - 数组/切片越界:访问超过长度的索引。
- Channel 误操作:向已关闭的 Channel 发送数据、重复关闭 Channel、或关闭未初始化的 nil Channel。
- 未初始化 Map:向一个 nil map 直接赋值(注意,访问不存在的 key 不会 Panic,而是返回零值)。
-
类型断言失败:
var a interface{} = 1; fmt.Println(a.(string))会 Panic,建议使用s, ok := a.(string)。 -
sync 计数为负数:如
sync.WaitGroup调用的Done()次数多于Add()的次数。 - Map 并发读写:多协程同时写同一个 Map(这个最致命,且无法被 recover 捕获!)。
2. Recover 的正确姿势
recover() 是 Go 提供的一个内置函数,用于捕获 Panic,防止程序崩溃。但它有极其严格的使用限制。
2.1 三大铁律
- 必须在 defer 中调用:因为 Panic 发生后,只有 defer 函数会被执行。
- 必须在发生 Panic 的同一个 Goroutine 中:Recover 无法捕获其他 Goroutine 抛出的 Panic。
- 必须直接调用:不能封装在辅助函数里调用(除非辅助函数本身就是 defer 的)。
2.2 代码实战:通用捕获中间件
在 Web 开发中,我们通常会在入口处加上 recover,防止某个请求挂掉导致整个服务崩溃。
package main
import (
"fmt"
"runtime/debug"
)
func safeHandler() {
defer func() {
if r := recover(); r != nil {
fmt.Printf("捕获到异常: %v\n", r)
// 打印堆栈信息,方便定位问题(生产环境必做!)
debug.PrintStack()
}
}()
// 模拟业务逻辑中的 Panic
var s []int
fmt.Println(s[0]) // Panic: index out of range
}
func main() {
safeHandler()
fmt.Println("服务继续运行,未崩溃...")
}
3. 并发场景下的 Panic(致命盲区)
这是面试中挂人最多的考点。
问题: 在主协程中写了 defer recover,能捕获子协程的 Panic 吗?
答案:不能!
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("主协程捕获到了:", r)
}
}()
go func() {
panic("子协程挂了")
}()
// 等待一下子协程执行
// select {}
}
运行结果: 程序直接崩溃,主协程的 recover 完全失效。
一针见血的原理:
Go 的 Panic 是 Goroutine 级别的。每个 Goroutine 都有自己的栈,Panic 只会在当前 Goroutine 的调用栈中向上冒泡。一旦冒泡到顶层(go func 的入口)还没被 recover,整个 Go 进程(Process)就会被 Runtime 强行终止。
解决方案:
如果你启动了子协程,必须在子协程内部使用 defer recover。
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Println("子协程内部捕获:", r)
}
}()
// 业务逻辑
panic("子协程挂了")
}()
4. 无法捕获的“死神”:Map 并发读写
有一种 Panic 是无法被 recover 捕获的,那就是 Concurrent map writes。
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("捕获:", r)
}
}()
m := make(map[int]int)
// 启动两个协程同时写 map
go func() { m[1] = 1 }()
go func() { m[1] = 2 }()
// 结果:fatal error: concurrent map writes
// 程序直接崩溃,defer 根本没机会执行
}
为什么?
这是 Go Runtime 的一种自我保护机制。并发写 Map 往往意味着程序逻辑已经严重错乱,数据可能已经损坏。与其让程序带着脏数据苟延残喘,不如直接杀掉。
解决办法:
- 使用
sync.Mutex加锁。 - 使用
sync.Map。
🏆 总结与思考
- 区分 Error 和 Panic:Error 是生活,Panic 是灾难。
- Recover 铁律:只能在 defer 中,只能捕获当前 Goroutine。
- 并发地狱:子协程的 Panic 必须由子协程自己兜底。
- 死神禁区:并发写 Map 导致的 Panic 无法被捕获,必须用锁。
下一篇,我们将进入模块三:核心数据结构,深入剖析面试中最常见的 Slice(切片),聊聊它和数组到底有什么不可告人的关系。
👉 关注专栏,每天进步一点点!