一句话结论
panic 中断当前 Goroutine 的正常执行,沿调用栈向上传播并执行沿途的 defer。recover 只能在 defer 中捕获 panic,阻止程序崩溃。
核心原理
panic 传播
func C() { panic("error in C") }
func B() { C() }
func A() { B() }
执行 A() →
1. C() 中 panic
2. 执行 C 中的 defer(如果有)
3. panic 传播到 B,执行 B 中的 defer
4. panic 传播到 A,执行 A 中的 defer
5. panic 到达 Goroutine 顶 → 程序崩溃
recover 使用
func safeCall() {
defer func() {
if r := recover(); r != nil {
log.Printf("recovered: %v\n", r)
// 可以在这里做清理
}
}()
panic("something went wrong")
}
// 程序不崩溃,继续执行
recover 只能捕当前 Goroutine 的 panic
go func() {
panic("in goroutine") // 整个程序崩溃!
}()
// 主 Goroutine 的 defer recover 捕获不到子 Goroutine 的 panic
常见用法:服务端中间件
// gRPC 拦截器中恢复 panic
func RecoveryInterceptor(ctx context.Context, req interface{},
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler,
) (resp interface{}, err error) {
defer func() {
if r := recover(); r != nil {
log.Printf("panic in %s: %v", info.FullMethod, r)
err = status.Error(codes.Internal, "internal server error")
}
}()
return handler(ctx, req)
}
高频面试问题
Q: panic 和 os.Exit 的区别?
30 秒回答: panic 会执行 defer、可以被 recover 捕获、输出调用栈。os.Exit 立即退出进程——不执行 defer、不可捕获。
Q: recover 写在 defer 外面有用吗?
30 秒回答: 没用。recover() 返回 nil——只有直接在 defer 函数中调用才有效。defer recover() 也没用(defer 的 recover 执行时已经没有 panic 上下文了)。
速记
panic 沿调用栈向上 + 执行沿途 defer。recover 只在 defer 函数中直接调用有效。子 Goroutine panic 主 Goroutine 捕获不到。服务端用 RecoveryInterceptor 兜底。
panic 的运行时机制
runtime.gopanic 的工作流程
当代码调用 panic(x) 时,编译器将其转换为调用 runtime.gopanic。以下是完整的执行流程:
panic("boom") 被调用
│
▼
┌─────────────────────────────────────┐
│ 1. runtime.gopanic(eface{_type, x})│
│ 获取当前 Goroutine 的 _panic 结构│
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 2. 将当前 _panic 压入 G.panic 链表 │
│ G.panic = &_panic{ │
│ arg: x, │
│ link: 旧的 G.panic, │
│ recovered: false, │
│ aborted: false, │
│ } │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 3. 遍历 defer 链(deferlink) │
│ for d = G.defer; d != nil; │
│ d = d.link { │
│ G.defer = d.link // 解绑 │
│ d.fn() // 执行 │
│ } │
│ // 每执行一个 defer,检查是否 │
│ // 有 recover 清除了 recovered │
└──────────────┬──────────────────────┘
│
▼
┌──────────┐
│recovered?│
└─┬──────┬─┘
true │ │ false
│ ▼
│ ┌──────────────────────┐
│ │ 4. 打印 panic 信息 │
│ │ printpanics(P) │
│ │ 5. 调用 fatalpanic │
│ │ 6. 整个程序 abort │
│ └──────────────────────┘
▼
┌──────────────────────┐
│ 返回到 recover 调用点 │
│ 程序继续正常运行 │
└──────────────────────┘
_panic 结构体(runtime/runtime2.go):
type _panic struct {
argp unsafe.Pointer // defer 的参数指针
arg any // panic 的参数(传给 recover 的值)
link *_panic // 链接到前一个 _panic(嵌套 panic 时形成链表)
pc uintptr // 程序计数器,用于栈追踪
sp unsafe.Pointer // 栈指针
recovered bool // 是否已被 recover 捕获
aborted bool // panic 是否被强行中止
goexit bool // 是否来自 runtime.Goexit
}
嵌套 panic:在 defer 中再次 panic
这是一个很重要的场景——在 recover 的 defer 中又发生了 panic:
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
panic("second panic") // 在 defer 中再次 panic
}
}()
panic("first panic")
}
// 输出:
// recovered: first panic
// panic: first panic
// panic: second panic
运行时行为: G.panic 链表变成 second_panic → first_panic。runtime 按顺序处理链表中的每个 _panic,打印它们的错误信息。如果所有 _panic 都未被 recover,程序最终 fatalpanic。
嵌套 panic 的 G.panic 链表:
second_panic ───link──→ first_panic ───link──→ nil
{arg: "second"} {arg: "first"}
recover 的运行时实现(runtime.gorecover)
recover() 编译为调用 runtime.gorecover:
// runtime/panic.go
func gorecover(argp uintptr) interface{} {
gp := getg() // 获取当前 Goroutine
p := gp._panic // 获取当前 panic 链表头
if p != nil && !p.recovered && argp == uintptr(p.argp) {
p.recovered = true // 标记为已恢复
return p.arg // 返回 panic 的参数
}
return nil
}
关键点:argp 校验 —— recover() 只恢复与当前 defer 调用帧匹配的 _panic。这就是为什么 defer recover() 无效的 runtime 原因。
为什么 defer recover() 无效
// ❌ 错误写法
defer recover()
// 等价于运行时:
// defer 注册时:funcval = runtime.gorecover
// defer 执行时:调用 gorecover()
// 但此时 gorecover 的调用帧是 defer 执行的那个栈帧
// argp 指向的是 defer return 的位置
// 而 panic 的 argp 是 panic 发生时的栈帧
// argp 不匹配 → 返回 nil
栈帧结构:
defer recover() 执行时:
┌─────────────┐
│ gorecover │ ← 当前 argp 指向这里
│ 的调用帧 │
├─────────────┤
│ defer 返回 │
│ 的栈帧 │
├─────────────┤
│ panic 发生 │ ← panic 的 argp 指向这里(不匹配!)
│ 的栈帧 │
└─────────────┘
正确写法中,recover() 被包含在外层 defer func() 中,argp 与 panic 时的栈帧匹配:
// ✅ 正确写法
defer func() {
recover() // 这里的 argp 指向 defer func 的调用帧 = panic 的触发帧
}()
panic 与 Goroutine 栈的关系
每个 Goroutine 有独立的 panic 链(G.panic 字段)。这意味着:
Goroutine A (main): Goroutine B (child):
G.panic = nil G.panic = nil
panic 只在 A 的栈中传播 panic 只在 B 的栈中传播
A 的 defer recover 无法 B 的 defer recover 无法
捕获 B 的 panic 捕获 A 的 panic
子 Goroutine panic 导致整个程序崩溃的原理
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered in main:", r)
}
}()
go func() {
panic("子 Goroutine 挂了") // main 的 recover 捕获不到!
}()
time.Sleep(time.Second)
fmt.Println("main 正常结束")
}
// 输出:
// panic: 子 Goroutine 挂了
// ... 整个程序 crash
原理: 子 Goroutine 的 panic 沿着它自己的 defer 链传播。如果子 Goroutine 的 defer 中没有 recover,panic 到达 Goroutine 栈顶 → runtime.fatalpanic → 整个进程 abort。不管主 Goroutine 是否有 recover,都不影响子 Goroutine 的 panic 传播。
自定义 panic value 的最佳实践
// ✅ 好:使用自定义错误类型,便于类型断言区分
type BizPanic struct {
Code int
Message string
Stack string
}
func (p *BizPanic) Error() string {
return fmt.Sprintf("[%d] %s", p.Code, p.Message)
}
func mayPanic() {
panic(&BizPanic{
Code: 500,
Message: "database connection lost",
Stack: string(debug.Stack()), // 预先捕获堆栈
})
}
// recover 中可以根据类型做不同处理
defer func() {
if r := recover(); r != nil {
switch v := r.(type) {
case *BizPanic:
log.Printf("业务 panic: code=%d, msg=%s", v.Code, v.Message)
case error:
log.Printf("错误 panic: %v", v)
default:
log.Printf("未知 panic: %v", v)
}
}
}()
// ❌ 不好:直接 panic 字符串,信息丢失
panic("something wrong") // 只有字符串,无法结构化处理
服务端 Recovery 中间件完整实现
HTTP 中间件
// HTTP Recovery 中间件(标准库风格)
func RecoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
// 获取堆栈
stack := debug.Stack()
// 记录完整错误日志
log.Printf("[PANIC] %s %s: %v\nStack:\n%s",
r.Method, r.URL.Path, rec, stack)
// 返回 500
http.Error(w, http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// 使用
mux := http.NewServeMux()
mux.HandleFunc("/api", handler)
http.ListenAndServe(":8080", RecoveryMiddleware(mux))
gRPC 拦截器(Unary + Stream)
// Unary 拦截器
func UnaryRecoveryInterceptor(ctx context.Context, req interface{},
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler,
) (resp interface{}, err error) {
defer func() {
if r := recover(); r != nil {
stack := debug.Stack()
log.Printf("[GRPC-PANIC] method=%s, panic=%v\nStack:\n%s",
info.FullMethod, r, stack)
// 转换为 gRPC 标准错误
err = status.Errorf(codes.Internal,
"internal server error: %v", r)
}
}()
return handler(ctx, req)
}
// Stream 拦截器
func StreamRecoveryInterceptor(srv interface{},
ss grpc.ServerStream, info *grpc.StreamServerInfo,
handler grpc.StreamHandler,
) (err error) {
defer func() {
if r := recover(); r != nil {
stack := debug.Stack()
log.Printf("[GRPC-STREAM-PANIC] method=%s, panic=%v\nStack:\n%s",
info.FullMethod, r, stack)
err = status.Errorf(codes.Internal,
"internal server error: %v", r)
}
}()
return handler(srv, ss)
}
net/http 中 panic 的默认恢复机制
net/http 标准库内置了 panic 恢复:每个 HTTP handler 在自己的 Goroutine 中执行,http.Server.Serve 方法中每个连接会调用 serverHandler.ServeHTTP,其中包含:
// net/http/server.go 简化的内置逻辑
// 每个请求 handler 实际运行于:
go func() {
defer func() {
if r := recover(); r != nil {
// 标准库默认只打印堆栈到 stderr
// 然后关闭连接(不向客户端返回 500)
log.Printf("http: panic serving %v: %v\n%s",
c.RemoteAddr(), r, buf)
}
}()
handler.ServeHTTP(w, r)
}()
所以即使你不写 RecoveryMiddleware,Go 的 HTTP Server 不会整个崩溃——但连接会直接关闭,客户端得不到应有的 500 响应。自定义中间件可以给客户端更友好的返回。
debug.Stack() 获取堆栈
import "runtime/debug"
func riskyFunc() {
defer func() {
if r := recover(); r != nil {
// 方式一:拿 []byte
stack := debug.Stack()
fmt.Printf("panic: %v\nstack:\n%s\n", r, stack)
// 方式二:只打印(写到标准错误)
debug.PrintStack()
}
}()
// ...
}
注意: debug.Stack() 会分配新的内存(make([]byte, ...)),高并发时注意性能开销。如果只是日志记录,可以在 recover 时按需调用,不要在正常流程中频繁调用。
高频面试问题
Q1: panic 在 defer 中再次 panic 会怎样?嵌套 panic 的打印顺序是什么?
30 秒回答: 两个 panic 都会打印,先打印第一个(原始)panic,再打印第二个(defer 中)的 panic。程序最终崩溃。
深入回答: 运行时用 G.panic 链表管理嵌套 panic。第一个 panic 触发 defer,defer 中又 panic → 新的 _panic 插入链表头部。defer 链执行完毕后,runtime.fatalpanic 遍历链表从尾到头打印所有 panic 信息,最后调用 exit(2) 终止进程。
继续追问:
"如果在第二个 panic 的 recover 中又 panic 呢?" → 形成三个 panic 的链表,全部打印后 crash。实际项目中极少见,说明代码逻辑有严重问题。
"defer 中 panic 能被外层 recover 捕获吗?" → 不能。defer 执行完毕后原来的 panic 依然在 G.panic 链上,新的 panic 也被加入。此时已经没有 defer 可以执行来 recover 它们了——除非在更外层的 defer 中 recover(也就是嵌套 defer),且 recover 只捕获链表头(最近的一个 panic)。
Q2: recover 返回 nil 的原因有哪些?
30 秒回答: (1) 没有 panic 在传播。(2) defer recover() 写法错误,argp 不匹配。(3) recover 在 defer 之外直接调用。(4) 当前 Goroutine 的 panic 已经被更内层的 defer recover 了。(5) 捕获的是其他 Goroutine 的 panic。
深入回答:
recover 返回 nil 的五种情况:
1. 无 panic: G.panic == nil
2. defer recover(): argp 不匹配 → gorecover 返回 nil
3. 直接调用: 不在 defer 中 → 编译为 gorecover 但无 panic 上下文
4. 已被捕获: p.recovered == true → 返回 nil
5. 跨 Goroutine: 另一个 G 的 panic 不在当前 G.panic 链上
继续追问:
"为什么 Go 不设计成 recover 在任何地方都能用?" → 这是 Go 团队故意设计的约束。panic/recover 是异常处理机制,但滥用会导致代码难以理解和调试。限制 recover 只能在 defer 中使用,强制开发者明确标记"这段代码可能恢复 panic",避免隐藏的异常吞噬。这和 Java 的 checked exception 哲学类似——显式胜于隐式。
Q3: 生产环境中 recover 应该放在哪一层?
30 秒回答: 放在最外层——框架入口、Goroutine 入口、中间件。不要在每个函数里写 recover,那会掩盖真正的 bug。
深入回答:
分层 recover 策略:
┌──────────────────────────────┐
│ gRPC/HTTP Interceptor │ ← 第一层:请求级别兜底
│ (统一 recovery,返回 500) │
├──────────────────────────────┤
│ Worker Goroutine │ ← 第二层:后台任务兜底
│ (每个 goroutine 入口 recover) │
├──────────────────────────────┤
│ 业务逻辑 │ ← ❌ 不要在这里加 recover
│ (让 panic 向上传播) │ 除非有明确的清理需求
└──────────────────────────────┘
继续追问:
"如果 recover 了,该不该重新 panic?" → 分场景。如果可以优雅处理(如返回错误给调用方),就别 re-panic。如果是不该发生的严重 bug(如 nil pointer dereference 在核心初始化逻辑),可以 recover 后打日志+记录堆栈+重新 panic,让上层感知问题。一个常见模式是
recover()后判断错误类型,只处理已知可恢复的,其他重新抛出。
速记
runtime.gopanic压入G.panic链表 → 遍历defer链 →gorecover通过argp校验匹配 → 设置p.recovered=true。嵌套 panic 形成链表,全打完后 fatalpanic。defer recover()argp 不匹配返回 nil。每个 Goroutine 独立 panic 链,子 Goroutine panic 主 Goroutine 救不了。服务端用 RecoveryInterceptor 兜底。debug.Stack() 拿堆栈。