文件、Socket、GPU 句柄和 C 内存都不受 Go 垃圾回收器直接管理。理想情况下,调用方会及时执行 Close,但复杂控制流中总有可能遗漏。过去,库作者常用 runtime.SetFinalizer 增加最后一道保险;然而 finalizer 会让对象重新变得可达,还要处理依赖顺序和引用环,稍有不慎就永远不会执行。Go 1.24 引入了 runtime.AddCleanup,把“观察包装对象的生命周期”和“释放底层资源”拆开,减少了 finalizer 常见的可达性陷阱。需要先说清楚:它仍然不是确定性析构,也不能取代显式 Close,更准确的定位是资源泄漏的兜底机制。
AddCleanup 做了什么
AddCleanup 是一个泛型函数,核心签名如下:
func AddCleanup[T, S any](ptr *T, cleanup func(S), arg S) Cleanup
ptr 是被观察的 Go 对象,arg 是清理函数真正需要的数据。当 ptr 不再可达后,运行时会在独立 goroutine 中调用 cleanup(arg)。返回的 runtime.Cleanup 提供 Stop 方法,可以取消尚未进入执行队列的清理任务。
典型场景是:ptr 指向一个 Go 包装对象,arg 只保存释放资源所需的独立状态。以下代码以类 Unix 系统的文件描述符为例,使用 sync.Once 保证显式关闭和兜底清理最多发起一次关闭操作:
type fileState struct {
fd int
once sync.Once
err error
}
func (s *fileState) close() {
s.once.Do(func() { s.err = syscall.Close(s.fd) })
}
包装对象指向状态,但状态不能反向引用包装对象。创建对象后,把二者分别作为 ptr 和 arg 传入:
type File struct {
state *fileState
cleanup runtime.Cleanup
}
func newFile(fd int) *File {
s := &fileState{fd: fd}
f := &File{state: s}
f.cleanup = runtime.AddCleanup(f, (*fileState).close, s)
return f
}
只要调用方仍能访问 f,清理函数就不应执行;当程序彻底丢失 f,后续垃圾回收才可能安排 s.close()。这里使用“可能”非常重要,因为运行时没有承诺具体执行时间。
为什么比 finalizer 更少出错
SetFinalizer(obj, fn) 会把 obj 本身交给 finalizer。垃圾回收器首次发现对象不可达时,会移除 finalizer 并执行 fn(obj),这一步又让对象重新可达。对象通常要等到后续垃圾回收周期再次判定不可达,内存才会释放。这种模型还要维护依赖顺序:若对象 A 指向 B,并且两者都有 finalizer,运行时需要先执行 A 的 finalizer;包含 finalizer 的引用环则可能因为不存在合法顺序而无法回收。AddCleanup 不把 ptr 传给清理函数,多个互相引用的对象同时失去可达性时,其 cleanup 可以按任意顺序执行,通常不会仅因对象形成引用环而泄漏。多个 cleanup 还能绑定到同一个对象,甚至绑定到同一块分配中的不同内部指针。
两者的调度方式也不同。finalizer 由单个 goroutine 串行执行,一个缓慢任务会拖住后面的任务;cleanup 可以与用户 goroutine 以及其他 cleanup 并发执行。不过执行顺序没有保证,清理逻辑必须能够独立运行并正确处理并发。因此,“更可靠”指的是更不容易因对象复活、依赖顺序和引用环而失效,并不表示 cleanup 必然执行。
显式 Close 仍是主路径
生产 API 应优先暴露 Close,让调用方配合 defer 确定资源释放时机。cleanup 只处理遗漏关闭的异常路径。显式关闭时,需要先调用 Stop,避免运行时再次释放同一个句柄。
func (f *File) Close() error {
f.cleanup.Stop()
f.state.close()
err := f.state.err
runtime.KeepAlive(f)
return err
}
Stop 只对尚未排队的任务有效。为了保证它确实移除 cleanup,必须让最初传给 AddCleanup 的对象在 Stop 返回前保持可达。将 runtime.KeepAlive(f) 放在最后,明确告诉运行时:到这里之前,f 都不能被视为不可达。sync.Once 则让重复调用 Close 仍返回首次关闭结果。顺序不能反过来:先关闭句柄、再取消 cleanup,会留下重复关闭的竞争窗口,而操作系统可能已经把相同编号分配给另一个资源。调用侧仍应使用 defer f.Close(),形成两层保障:Close 负责正常路径,AddCleanup 负责调用方遗忘关闭时的泄漏缓解。
两个可达性陷阱
cleanup 或 arg 不能让运行时沿指针关系重新找到 ptr,否则 ptr 永远可达,cleanup 也永远不会运行。不能把对象自身作为参数,也不能在清理闭包中捕获它。下面是最直接的错误:
runtime.AddCleanup(f, func(*File) {}, f)
当前 Go 1.26 实现会在注册阶段拒绝这种用法和直接捕获 f 的闭包并触发 panic,还会检查 arg 是否直接指向 ptr 所在的分配块;Go 1.24 最初只检查 arg 与 ptr 直接相等的情况。这些检查只是针对常见错误的保护措施,并非完整的引用分析。运行时仍无法发现任意深度的间接引用,例如 arg 指向一个容器,而容器又指回 f,依然会形成永不执行的 cleanup。稳妥原则是使用无捕获的包级清理函数,并让 arg 只携带释放资源所需的最小信息。
另一个陷阱是过早清理。函数参数或方法接收者在最后一次被提及后,就可能被运行时视为不可达。假设方法只把 f.state.fd 传给系统调用,cleanup 可能在系统调用尚未完成时关闭文件描述符。此时应在底层操作之后调用 KeepAlive:
func (f *File) Write(p []byte) (int, error) {
n, err := syscall.Write(f.state.fd, p)
runtime.KeepAlive(f)
return n, err
}
KeepAlive 标记对象必须保持可达的最后位置,但它不是通用同步原语,也不会让 cleanup 与业务代码自动互斥;共享的可变状态仍需使用正常的并发控制。
不能依赖的边界
cleanup 不保证在程序退出前运行,所以不能依赖它刷新 bufio.Writer、提交事务、写入审计日志或完成任何影响正确性的操作。进程结束时必须发生的动作,应由显式关闭流程负责。零字节对象可能与其他对象共享地址,包级变量初始化期间创建的对象可能由链接器分配,这两类对象的 cleanup 都不保证运行。对于大约 16 字节以内且不含指针的微小对象,运行时还可能批量分配;只要同一批次中始终有对象存活,某个不可达对象的 cleanup 也可能迟迟不执行。
同一对象上的多个 cleanup 没有执行顺序,并且可能与其他任务并发。因此,清理函数应当短小、幂等、线程安全,不能假设其他资源已经释放或尚未释放。耗时清理应启动新的 goroutine,避免阻塞其他 cleanup。此外,ptr 不能为 nil,也不能指向实验性 arena 中分配的对象,否则 AddCleanup 会触发 panic。若同一对象同时注册 finalizer 和 cleanup,cleanup 要等 finalizer 执行完成、对象再次不可达且不再关联 finalizer 后才有机会运行,不建议混用两套机制。
写在最后
runtime.AddCleanup 的核心改进,是让清理函数只接触底层资源参数,而不接触被观察对象。这个分离降低了对象复活、引用环和依赖排序带来的复杂度,也允许多个 cleanup 并发执行。
工程上的优先级没有改变:显式 Close 与 defer 负责确定性释放,Stop 避免重复清理,KeepAlive 划定对象必须存活的边界,AddCleanup 只承担最后一道兜底。把它当成泄漏保险,而不是析构函数,才能得到更稳妥的资源生命周期管理。