作为Go开发者,我们都知道使用recover()可以捕获异常,防止程序崩溃。但你可能不知道,有些异常是recover()也无法捕获的"致命杀手"!

在一次线上事故中,某部门的服务因为一个简单的编程错误导致崩溃,尽管他们已经在代码中使用了recover()进行保护。

这让他们意识到,很多Go开发者对recover()的能力存在误解。

下面就来深入探讨哪些异常可以被recover()捕获,而哪些不能,以及为什么。

哪些异常可以被Recover捕获?

在Go语言中,以下常见的异常情况是可以通过recover()捕获的:

1. 切片或数组下标越界

func main() {
    defer func() {
        if err := recover(); err != nil {
            fmt.Println("err:", err)
        }
    }()
    nums := []int{}
    fmt.Println(nums[1]) // 越界访问,触发panic
}

2. 空指针解引用

func main() {
    defer func() {
        if err := recover(); err != nil {
            fmt.Println("err:", err)
        }
    }()
    var str *string
    fmt.Println(*str) // 空指针解引用
}

3. 未初始化的map进行写操作

func main() {
    defer func() {
        if err := recover(); err != nil {
            fmt.Println("err:", err)
        }
    }()
    var mp map[string]int
    mp[""] = 1 // 对未初始化的map进行写操作
}

4. 类型断言失败

func main() {
    defer func() {
        if err := recover(); err != nil {
            fmt.Println("panic")
        }
    }()
    var str interface{} = "123"
    _ = str.(int) // 类型断言失败
}

这些异常都是通过runtime.panic()抛出的,因此可以被recover()捕获。

无法被Recover捕获的"致命异常"

以下是我们今天的重点——那些即使使用了recover()也无法捕获的致命异常:

1. 并发读写Map(最常见!)

func main() {
    defer func() {
        if err := recover(); err != nil {
            fmt.Println("err:", err) // 这行代码不会执行
        }
    }()

    m := map[string]int{}
    // goroutine 1: 写操作
    go func() {
        for {
            m["x"] = 1
        }
    }()
    // goroutine 2: 读操作
    for {
        _ = m["y"]
    }
}

当Go检测到map的并发读写时,会抛出fatal error: concurrent map writes,这是一个不可恢复的异常

为什么无法恢复? 因为当检测到数据竞争时,map的内部结构可能已经被破坏,继续运行可能导致不可预知的结果。

2. 内存溢出(OOM)

func main() {
    defer func() {
        if err := recover(); err != nil {
            fmt.Println("err:", err) // 这行代码不会执行
        }
    }()

    _ = make([]int64, 1<<40) // 尝试分配超大内存
}

内存不足会导致程序无法继续执行,这种情况下的异常无法被恢复。

3. 栈内存耗尽

func main() {
    defer func() {
        if err := recover(); err != nil {
            fmt.Println("err:", err) // 这行代码不会执行
        }
    }()

    var fun func([1000]int64)
    fun = func(i [1000]int64) {
        fun(i) // 无限递归,导致栈溢出
    }
    fun([1000]int64{})
}

无限递归会导致栈空间耗尽,报错:runtime: goroutine stack exceeds 1000000000-byte limit

4. 启动nil函数

func main() {
    defer func() {
        if err := recover(); err != nil {
            fmt.Println("err:", err) // 这行代码不会执行
        }
    }()

    var f func()
    go f() // 启动一个nil函数
}

这会导致:fatal error: go of nil func value

5. 程序死锁

func main() {
    defer func() {
        if err := recover(); err != nil {
            fmt.Println("err:", err) // 这行代码不会执行
        }
    }()

    // 所有goroutine都处于休眠状态,产生死锁
    <-make(chan int)
}

当所有goroutine都处于休眠状态时,Go运行时会检测到死锁并抛出致命错误。

为什么这些异常无法被Recover捕获?

本质区别在于异常的产生机制:

  • 可恢复的异常:通过runtime.panic()抛出
  • 不可恢复的异常:通过runtime.throw()runtime.fatal()抛出,这些属于系统级致命错误

不可恢复的异常通常意味着程序状态已经严重损坏,继续运行可能会导致更严重的问题。

如何预防这些致命异常?

1. 并发安全编程

  • 使用sync.Mutexsync.RWMutex保护共享数据
  • 使用sync.Map代替普通map进行并发读写
  • 使用channel进行goroutine间通信

2. 资源控制

  • 限制递归深度,避免无限递归
  • 预估切片和map的容量,避免频繁扩容
  • 对大数据集使用流式处理

3. 测试与检测

  • 使用go test -race进行竞态检测
  • 进行压力测试,模拟高并发场景
  • 编写单元测试覆盖边界条件

写在最后

虽然recover()是Go中强大的错误处理机制,但它不是万能的。对于并发读写map、内存溢出、栈溢出等致命错误,recover()也无能为力。

正确的做法是:预防优于补救。通过良好的编程习惯、严格的测试和适当的资源控制,避免触发这些不可恢复的异常,才是保证程序稳定性的根本之道。