Go 中的 defer、panic 和 recover:何时使用

1. 概述

Go 没有 try/catch。相反,该语言提供了三个工具:defer 用于清理资源,panic 用于报告不可恢复的错误,以及 recover 用于在特定边界处阻止 panic。

本文解释了每个工具的机制、常见陷阱,最重要的是何时使用、何时不使用。读完后,你将知道如何正确使用 defer,何时 panic 是合理的,以及如何使用 recover 来保持服务器不会因单个请求错误而崩溃。

2. defer

defer 延迟函数调用,直到封闭函数结束。函数可以通过 return 正常结束或因 panic 结束。在这两种情况下,所有已 defer 的调用都会运行。

2.1. LIFO 执行顺序

package main

import "fmt"

func main() {
	for i := 1; i <= 3; i++ {
		defer fmt.Println("defer", i)
	}
	fmt.Println("done")
}

每次遇到 defer,Go 就会把调用推入函数的私有栈。当 main 结束时,调用按相反顺序弹出:

done
defer 3
defer 2
defer 1

这个 LIFO 顺序是有原因的。后打开的资源通常依赖于先打开的资源。例如,你先打开连接再打开事务,所以事务必须在连接之前关闭。

2.2. 参数立即求值

这是第一个陷阱。defer 调用的函数参数在遇到 defer 时被计算,而不是在实际调用运行时。

package main

import "fmt"

func main() {
	total := 10
	defer fmt.Println("deferred total:", total)

	defer func() {
		fmt.Println("closure total:", total)
	}()

	total = 20
	fmt.Println("current total:", total)
}
current total: 20
closure total: 20
deferred total: 10

在第一个调用中,值 10 在遇到 defer 时立即被捕获。第二个调用中的闭包则不同。它引用了变量 total,所以运行时会读取最新值 20。

当你需要最新值时,比如测量时间或记录状态,使用闭包。

2.3. 使用命名返回值修改返回值

Deferred 闭包可以读取和写入函数的命名返回值。这个技巧在 Close() 可能返回错误时非常有用,例如当写入文件且数据只在关闭时真正被 flush。

package main

import (
	"errors"
	"fmt"
	"os"
)

func writeReport(path string, data []byte) (err error) {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer func() {
		err = errors.Join(err, f.Close())
	}()

	_, err = f.Write(data)
	return err
}

func main() {
	err := writeReport("/tmp/report.txt", []byte("Order #42: 3 items\n"))
	fmt.Println("error:", err)
}
error: <nil>

语句 return err 将值赋给 err 变量,然后 deferred 闭包才运行。闭包使用 errors.Join 将 f.Close() 的错误合并到 err 中(从 Go 1.20 开始)。如果两个错误都是 nil,errors.Join 返回 nil。

如果你只写 defer f.Close(),关闭文件时的错误会被忽略,没人知道发生了什么。

注意: 对于只读文件,defer f.Close() 就足够了。关闭只读文件时出错几乎不影响数据。

2.4. 陷阱:循环中的 defer

func processAll(paths []string) error {
	for _, p := range paths {
		f, err := os.Open(p)
		if err != nil {
			return err
		}
		defer f.Close() // 错误:只有在 processAll 结束时才会关闭
		// ... 读取 f ...
	}
	return nil
}

defer 附加到函数,而不是块或循环。对于 10,000 个文件,上面的代码会同时保持 10,000 个文件描述符打开,很容易遇到 too many open files 错误。

修复方法是将循环体提取到单独的函数中:

func processAll(paths []string) error {
	for _, p := range paths {
		if err := processFile(p); err != nil {
			return err
		}
	}
	return nil
}

func processFile(path string) error {
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close() // 当 processFile 返回时立即关闭
	// ... 读取 f ...
	return nil
}

3. panic

panic 停止当前 goroutine 的正常执行流程。Go 开始展开 stack:每个函数都返回,每个函数的 defer 仍然会运行。当到达 goroutine 堆栈顶部且没有人 recover 时,程序打印堆栈跟踪并以代码 2 退出。

3.1. 观察展开过程

package main

import "fmt"

func main() {
	defer fmt.Println("main: deferred")
	processOrder()
	fmt.Println("never printed")
}

func processOrder() {
	defer fmt.Println("processOrder: deferred")
	panic("inventory service returned invalid state")
}
processOrder: deferred
main: deferred
panic: inventory service returned invalid state

goroutine 1 [running]:
main.processOrder()
	/tmp/main.go:13 +0x...
exit status 2

你看到两个 defer 都在程序死亡前运行。这正是为什么 defer mu.Unlock() 或 defer tx.Rollback() 在有 panic 时仍然安全的原因。

Panic 也可能来自运行时而不仅仅是显式的 panic() 调用。常见情况包括访问超出范围的索引、解引用 nil 指针、写入 nil map 或整数除以 0。

3.2. 何时应该 panic

Go 的通用规则:可预见的错误应该返回 error。例如文件不存在、输入格式错误或网络超时。只在以下几种情况下使用 panic。

1. 编程错误,即违反不变量。 这是如果代码正确就“不可能发生”的状态,继续运行只会破坏数据。

func (s OrderStatus) String() string {
	switch s {
	case Pending:
		return "PENDING"
	case Paid:
		return "PAID"
	case Shipped:
		return "SHIPPED"
	}
	panic(fmt.Sprintf("unknown OrderStatus: %d", s))
}

2. MustXxx 函数用于初始化。 标准库有 regexp.MustCompile 和 template.Must。你可以为配置编写类似的函数:

package config

import (
	"fmt"
	"strconv"
)

func MustParsePort(s string) int {
	port, err := strconv.Atoi(s)
	if err != nil || port < 1 || port > 65535 {
		panic(fmt.Sprintf("invalid port %q", s))
	}
	return port
}

var emailPattern = regexp.MustCompile(`^[^@\s]+@[^@\s]+$`)

如果配置错误,应用程序不应该启动。在启动时立即 panic 帮助你尽早发现错误,而不是让服务器以损坏的状态运行。

注意: 不要将 MustXxx 用于来自用户或请求的数据。坏的输入不应该导致整个进程崩溃。

4. recover

recover() 停止展开过程并返回传递给 panic 的值。这个函数只有在直接从 deferred 函数内调用时才有效。在任何其他地方调用它会返回 nil 且不做任何事。

4.1. 将 panic 转换为 error

package main

import "fmt"

func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("safeDivide(%d, %d): %v", a, b, r)
		}
	}()
	return a / b, nil
}

func main() {
	fmt.Println(safeDivide(10, 2))
	fmt.Println(safeDivide(10, 0))
}
5 <nil>
0 safeDivide(10, 0): runtime error: integer divide by zero

当 b == 0 时,运行时 panic。Deferred 闭包使用 recover() 捕获 panic,然后赋值给命名返回值 err。函数正常返回,result 保持零值 0。

这个例子只是为了说明。实际上,你应该检查 b == 0 并直接返回 error。

4.2. 陷阱:recover 必须直接调用

func logPanic() {
	if r := recover(); r != nil {
		log.Println("recovered:", r)
	}
}

func handlerA() {
	defer logPanic() // OK:logPanic 就是 deferred 函数
	panic("boom A")
}

func handlerB() {
	defer func() {
		logPanic() // 不工作:recover 在更深一层
	}()
	panic("boom B")
}

handlerA 成功 recover。handlerB 仍然会导致程序崩溃,因为 recover 在 logPanic 内部被调用,而 logPanic 是被 deferred 函数调用的,本身不是 deferred 函数。

4.3. 陷阱:recover 不会捕获其他 goroutine 的 panic

每个 goroutine 都有自己的堆栈。子 goroutine 中的 panic 导致整个进程崩溃,无论 main 是否有 recover。

func main() {
	defer func() { recover() }() // 对下面的 goroutine 无用

	go func() {
		panic("worker crashed") // 进程仍然死亡
	}()
	time.Sleep(time.Second)
}

如果你运行后台作业而无法完全控制其中的代码,请将其包装起来:

func SafeGo(name string, fn func()) {
	go func() {
		defer func() {
			if r := recover(); r != nil {
				log.Printf("goroutine %s panicked: %v\n%s", name, r, debug.Stack())
			}
		}()
		fn()
	}()
}

debug.Stack() 在 runtime/debug 包中。它帮助你记录足够的堆栈跟踪以便以后调试。

4.4. 在 HTTP 边界处 Recover

一个放置 recover 的合理位置是请求边界。一个失败的请求不应该影响其他请求。

func RecoverMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if rec := recover(); rec != nil {
				if rec == http.ErrAbortHandler {
					panic(rec) // 让 net/http 按设计处理 abort
				}
				log.Printf("panic on %s %s: %v\n%s", r.Method, r.URL.Path, rec, debug.Stack())
				http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

net/http 已经为每个连接 recover panic,但它只记录日志然后关闭连接,所以客户端不会收到适当的响应。上面的中间件返回明确的 500 代码,同时记录带有方法和路径的日志以便轻松追踪。

5. 为 panic 和 recover 编写测试

Go 使用标准 testing 包,不需要外部依赖。

package config

import (
	"strings"
	"testing"
)

func TestSafeDivide_ByZero_ReturnsError(t *testing.T) {
	result, err := safeDivide(10, 0)

	if err == nil {
		t.Fatal("expected error, got nil")
	}
	if result != 0 {
		t.Errorf("expected result 0, got %d", result)
	}
	if !strings.Contains(err.Error(), "divide by zero") {
		t.Errorf("unexpected error message: %v", err)
	}
}

func TestMustParsePort_InvalidInput_Panics(t *testing.T) {
	defer func() {
		r := recover()
		if r == nil {
			t.Fatal("expected panic, got none")
		}
		if msg, _ := r.(string); !strings.Contains(msg, `"abc"`) {
			t.Errorf("unexpected panic value: %v", r)
		}
	}()

	MustParsePort("abc")
}

func TestMustParsePort_ValidInput(t *testing.T) {
	if got := MustParsePort("8080"); got != 8080 {
		t.Errorf("expected 8080, got %d", got)
	}
}
go test ./... -v
=== RUN   TestSafeDivide_ByZero_ReturnsError
--- PASS: TestSafeDivide_ByZero_ReturnsError (0.00s)
=== RUN   TestMustParsePort_InvalidInput_Panics
--- PASS: TestMustParsePort_InvalidInput_Panics (0.00s)
=== RUN   TestMustParsePort_ValidInput
--- PASS: TestMustParsePort_ValidInput (0.00s)
PASS

第二个测试使用 defer 和 recover 本身来断言函数有 panic,以及以正确的消息 panic。如果你使用 testify,assert.PanicsWithValue 以更少的代码达到相同的结果。

6. 何时不应该使用

  • 不要像异常一样使用 panic/recover。 写 panic(ErrNotFound) 然后在更上层 recover 会使控制流难以阅读,也违反了 Go 的约定。应该返回 error。
  • 在公共库中不要 panic,当错误来自调用者的输入时。你库的用户不会期望他们的进程死亡。
  • 不要 recover 然后无声地忽略。 至少要记录 panic 值和堆栈跟踪。如果状态已损坏,例如共享数据写入不完整,让进程死亡然后让编排器重启。
  • 不要在长循环中 defer(参见 2.4 节)。
  • 不用太担心 defer 的性能。 从 Go 1.14 开始,open-coded defer 在大多数情况下几乎没有成本。只在 profiler 指出它是热点时才优化。

注意: 从 Go 1.21 开始,panic(nil) 不再让 recover() 返回 nil。相反,recover() 返回 *runtime.PanicNilError。使用 recover() == nil 来检查“没有 panic”的旧代码现在更准确了。

7. 结论

这三个工具有非常不同的角色:

  • defer 是日常使用的工具来清理资源。记住两点:参数在遇到 defer 时立即求值,defer 附加到函数而不是循环。结合命名返回值,defer 帮你不遗漏来自 Close() 的错误。
  • panic 用于编程错误和不可恢复的初始化错误。对于其他错误,返回 error。
  • recover 应该只出现在边界处:HTTP 中间件、worker goroutine 或调用你无法控制的代码的地方。它必须直接在 deferred 函数内调用,且只能捕获该 goroutine 自己的 panic。

掌握这些界限,你就能写出既安全又符合 Go 精神的代码:错误是值,panic 是真正的异常。

golangdeferpanicrecover错误处理