一句话结论

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() 拿堆栈。