Go 12:panic 时清理和 recover 的边界
11 已把空文本、查不到任务与文件读取失败变成可判断的错误。统计函数真的发生 panic 时,前面通过 defer 注册的清理还会执行吗?另一个常见误用是在普通代码中调用 recover(),以为它能给任何后续 panic 提供兜底。这两件事有不同条件,不能凭一次 go test 无失败就宣称资源安全。
本章继续沿用同一累计工程的 analyzeTask、readAndClose 与 CLI。analyzeTask 通过 analyzeTaskUsing(text, max, analyze) 将分析函数作为参数交给一个窄的测试接缝,生产入口仍传 stats.Analyze。panic 由测试替身注入;正式 CLI 没有安装吞掉未知 panic 的全局 recover。完整工程、正反原始证据及归档校验值随本篇素材包提供。
独立复跑步骤列出本章正反例、源码校验及运行命令。
先确认注入真的抵达统计函数
TestPanicsAndCleanup 传给 analyzeTaskUsing 一个会执行 panic("injected statistics panic") 的分析函数。测试在调用处先注册恢复函数,再注册一个增加 cleanupCalls 的清理函数。按照 defer 后进先出,panic 展开时先执行清理,再由同一调用栈的延迟函数直接调用 recover()。固定 Go 1.27.1 上,独立聚焦测试输出 statistics: recovered=injected statistics panic cleanupCalls=1,退出 0。恢复函数属于测试调用方;没有把不明来源的生产 panic 冒充可重试业务错误。
测试最初曾失败:原来的 analyzeTaskUsing 虽接收注入参数,却仍调用固定的 stats.Analyze(text)。focused-before.* 和 test-before.* 中 recovered=<nil> cleanupCalls=1 且测试退出 1。这只能证明清理函数在正常路径被调用,不能证明 panic 路径已被覆盖。将调用真正改成 analyze(text) 后,聚焦和两包全量测试重新运行、退出 0,原始日志另存 focused.* 与 test.*。两个版本的结果都保留在 writing-plans/go-language/evidence/12/,避免将第一次失败悄悄覆盖成成功叙述。
读文件时 panic,资源是否仍会关闭
05 的 readAndClose 用 defer 注册一次 Close。本章在其真实调用路径里换上 panicReader,让 Read 直接 panic。测试函数在更外层用延迟函数恢复,随后断言 trackedReader.closeCalls == 1。聚焦测试实测行是 read: recovered=injected read panic closeCalls=1,与统计 panic 分开断言;这条证据不依赖文件描述符的偶然计数,也不假装 Close 在 panic 时能够修复读取结果。
1 | |
Go 语言规范在 panic 展开时仍按栈逐层执行 defer;recover 只有在同一个 goroutine 中由延迟函数直接调用,才能取得正传播的 panic 值。短程序 testdata/recover-outside-defer.go 先在普通语句中打印 recover-outside-defer=<nil>,再显式 panic。编译后直接运行的二进制 stdout 是这行,stderr 以 panic: unhandled failure 开头,退出 2。这说明「在某处先调用一次 recover」不起保护作用;堆栈地址只是本次 Linux 构建的原始输出,不是跨版本稳定格式。
本章并未运行跨 goroutine 的 recover 实验。规范对恢复的同一 goroutine 条件已经明确,但在还没有任务 worker 与退出协议之前,不制造一个未等待的 goroutine 再假称已经验证后台任务边界。到 15 引入 goroutine 时,需要有可控的退出与等待,不能只观察主函数是否返回。当前测试只涉及单一 goroutine 的两处注入路径,正常工作流仍是顺序 CLI。
不让 panic 接管普通业务错误
TestPanicsAndCleanup 还给 analyzeTaskUsing 传入空白输入,并提供一个会记录是否被调用的分析函数。实际 errors.Is(err, ErrInvalidInput) 为真,分析函数未被调用,测试也没有进入 panic/recover 分支。原先的非法参数、文件打开失败及缺失任务都仍通过 11 的错误分类返回。编译所得 CLI --text 'Go 写 Go' 输出 words=3 bytes=9 runes=7、退出 0;空白文本与缺失文件按原来约定退出 2。对应环境、命令、双流和退出码在 evidence/12/valid.*、invalid.*、file-failure.*。从包中独立解包后又复测了统计注入、文件资源清理、反例与 CLI 正反路径;网页生成本身不提供这些证明。
练习:如果把测试中注册清理的 defer 放到 analyzeTaskUsing 返回以后,panic 路径的 cleanupCalls 会是多少?答案:panic 展开会直接跳过后续普通语句,注册动作根本没有发生;这与已注册但尚未执行的 defer 不同。检查清理责任时,必须先定位拥有资源的函数在哪里注册 defer,再分别检查正常返回和 panic 展开。
当前还没有后台任务的 panic 保护策略:不能把本测试中恢复的 panic 等价为任务已失败、资源全释放或 worker 已退出。数据库提交、HTTP 应答、取消与退出确认、race、其他平台和发布均 NOT_RUN。源码 tag go1.27.1、镜像查询的 SHA 862c888e612ac346c7c4d99c9392bdfd265f33b0 已登记,官方 Gitiles 尚未交叉核验。下一篇才检验泛型约束对当前结果集合能做什么,别用泛型掩盖已有的错误和清理边界。
参考资料
- Go 语言规范:Defer statements、Handling panics。本版规范的实际章节、实验失败再修复的记录与未运行的跨 goroutine 场景在
writing-plans/go-language/research/12.md。






