Go 并发中如何捕获子协程抛出的 panic?

在 Go 的并发世界里,recover 只能捕获当前 goroutine 的 panic。这意味着,子协程里发生的 panic 必须在它自己内部用 defer 和 recover 来处理,否则整个进程都可能崩溃。这里面的门道不少,既要避免 recover 后继续执行导致状态错乱,又要防止二次 panic,通常还需要通过 channel 等方式显式地将错误传递出去。
子协程 panic 无法被主 goroutine 的 recover 捕获
这是 Go 并发编程里一个经典的“坑”。recover 这个机制,只对**当前 goroutine** 有效。换句话说,你在主 goroutine 里写的 defer func() { recover() }(),对于子协程里发生的 panic 是完全无效的。很多开发者踩过这个坑:明明加了 recover,程序却照样崩溃,控制台直接打出完整的堆栈跟踪信息,然后进程退出。
典型的错误现象是这样的:panic: runtime error: index out of range 直接终止了程序,哪怕外面包裹了 waitgroup.Wait() 也拦不住。原因在于,每个子协程都是一个独立的调度单元,如果它自己没有设置 recover,一旦 panic 发生,就会直接终止并将异常上报给 runtime,父 goroutine 对此既不知情,也不会中断,更无法接管。
- 子协程 panic 后,这个异常并不会自动传播到主 goroutine,但整个进程仍然可能退出,尤其是在主线程很快结束的情况下。
- 即使主流程还在运行,后台的 worker 协程可能已经静默退出了,这会导致资源泄漏、任务中断,监控系统也无法察觉。
- 关键在于,
recover()必须写在子协程的内部,并且必须包裹在defer的匿名函数里。直接写成defer recover()是无效的,因为它会在注册时就立即执行。
每个子协程必须独立注册 defer + recover
处理子协程 panic 的原则,不是“统一兜底”,而是“各自负责”。只要子协程里执行了不可信的操作——比如解析用户输入、访问未判空的 map、进行类型断言,或者调用了第三方库——就必须自己包一层 defer/recover。
来看一个标准写法示例:
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("goroutine panicked: %v", r)
// 建议加上 debug.Stack() 获取完整堆栈
}
}()
m := make(map[string]int
_ = m["missing"] // 触发 panic,但会被捕获
}()
recover的注册时机必须早于可能触发 panic 的代码,这依赖于defer的特性来保证。- 捕获到 panic 后,千万别只是打印一句日志就完事。在生产环境中,应该注入 trace ID、记录完整的堆栈信息(使用
debug.Stack(),而不是debug.PrintStack()),并触发告警或上报到监控系统。 - recover 之后,绝不能继续执行原来的业务逻辑,因为程序状态很可能已经不一致了。正确的做法是立即
return,或者转入明确的错误处理分支,比如关闭资源、执行重试或降级策略。
用 safeGo 封装避免重复写 defer/recover
重复手写 defer/recover 不仅容易遗漏,还容易出错。封装一个通用的函数是更合理的选择,但要注意,不能因此牺牲了可观察性。
这里提供一个轻量级的封装示例,它包含了日志和堆栈记录:
func safeGo(f func(), logger *log.Logger) {
go func() {
defer func() {
if r := recover(); r != nil {
stack := debug.Stack()
if logger != nil {
logger.Printf("panic in goroutine: %+v\n%s", r, stack)
}
}
}()
f()
}()
}
- 调用时,传入具体的业务函数和一个 logger:
safeGo(func() { doRiskyWork() }, myLogger)。 - 要特别警惕的是,避免在
recover代码块里再次触发 panic。比如,向已关闭的 channel 发送数据,或者对 nil map 进行写入。这会导致原始的 panic 信息被覆盖,彻底丢失问题线索。 - HTTP handler、定时任务、长连接守护协程,这些都是 panic 漏捕的高频区。在这些地方,务必使用
safeGo或等效的机制来兜底。
recover 后的状态不可信,别试图“续上”原逻辑
这一点至关重要。recover 只是阻止了 goroutine 的崩溃,但它**不会回退调用栈、不会重置程序状态、也不会自动从 panic 发生的那行代码之后继续执行**。一个常见的误用,就是在 recover 之后接着往下跑,结果导致数据错乱或逻辑跳变。
- panic 发生后,函数实际上已经退出了。此时,局部变量、锁的状态、channel 的状态都可能处于一个不确定的中间态。
- 不要在 recover 块里调用那些可能再次 panic 的函数,比如又去读取一个未初始化的指针。
- 如果需要向主 goroutine 传递错误,应该使用
chan error或者sync.Once配合atomic.Value来显式地通知,而不是依赖“恢复后继续执行”这种隐式的同步方式。 - Context 的取消和 panic 处理需要分开考虑:
ctx.Done()不会拦截 panic,同样,panic 也不会主动去关闭 context。
在实际开发中,最容易被忽略的就是:recover 之后没有做显式的退出处理,或者错误地把 recover 当成常规的错误处理流程来用。必须明确,recover 的本质是一个异常逃生舱,而不是常规的错误流。一旦 panic 发生,那个 goroutine 的业务上下文就已经失效了,硬撑着往下走,往往比直接退出更危险。
