第 5 篇:Go 语言的错误哲学:Panic 与 Recover 机制

面试官提问:

"Go 程序在什么情况下会发生 Panic?"
"Recover 能捕获所有 Panic 吗?比如并发场景下的?"
"能手写一个通用的 Panic 捕获中间件吗?"


大家好,我是公号「左诗右码」作者 Alex。

在 Go 语言的设计哲学中,错误(Error)和异常(Panic)是两个完全不同的概念。

  • Error:是业务逻辑的一部分,是预期的,应该被处理(如文件打开失败、网络超时)。
  • Panic:是严重的程序崩溃,是不可预期的(如空指针引用、数组越界)。

面试官问 Panic,通常不是想听你列举报错类型,而是想考察你对程序稳定性和并发安全的理解。今天,我们就来聊聊如何优雅地处理“炸弹”。


1. 触发 Panic 的常见场景(避雷指南)

除了手动调用 panic(),以下是开发中最容易踩的雷:

  1. 空指针引用:var p *int; *p = 1,或者访问一个 nil 结构体指针的成员。
  2. 数组/切片越界:访问超过长度的索引。
  3. Channel 误操作:向已关闭的 Channel 发送数据、重复关闭 Channel、或关闭未初始化的 nil Channel。
  4. 未初始化 Map:向一个 nil map 直接赋值(注意,访问不存在的 key 不会 Panic,而是返回零值)。
  5. 类型断言失败:var a interface{} = 1; fmt.Println(a.(string)) 会 Panic,建议使用 s, ok := a.(string)。
  6. sync 计数为负数:如 sync.WaitGroup 调用的 Done() 次数多于 Add() 的次数。
  7. Map 并发读写:多协程同时写同一个 Map(这个最致命,且无法被 recover 捕获!)。

2. Recover 的正确姿势

recover() 是 Go 提供的一个内置函数,用于捕获 Panic,防止程序崩溃。但它有极其严格的使用限制。

2.1 三大铁律

  1. 必须在 defer 中调用:因为 Panic 发生后,只有 defer 函数会被执行。
  2. 必须在发生 Panic 的同一个 Goroutine 中:Recover 无法捕获其他 Goroutine 抛出的 Panic。
  3. 必须直接调用:不能封装在辅助函数里调用(除非辅助函数本身就是 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 往往意味着程序逻辑已经严重错乱,数据可能已经损坏。与其让程序带着脏数据苟延残喘,不如直接杀掉。

解决办法:

  1. 使用 sync.Mutex 加锁。
  2. 使用 sync.Map。

🏆 总结与思考

  1. 区分 Error 和 Panic:Error 是生活,Panic 是灾难。
  2. Recover 铁律:只能在 defer 中,只能捕获当前 Goroutine。
  3. 并发地狱:子协程的 Panic 必须由子协程自己兜底。
  4. 死神禁区:并发写 Map 导致的 Panic 无法被捕获,必须用锁。

下一篇,我们将进入模块三:核心数据结构,深入剖析面试中最常见的 Slice(切片),聊聊它和数组到底有什么不可告人的关系。

👉 关注专栏,每天进步一点点!

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容