文件描述符、Socket、GPU 句柄以及 C 分配的内存,都不在 Go 垃圾回收器的直接管理范围内。正常情况下,调用方应通过 Close 及时释放资源;但一旦控制流变复杂,遗漏关闭的情况就很难完全避免。Go 1.24 引入的 runtime.AddCleanup,正是为这种“调用方忘记清理”的场景提供更稳妥的兜底机制。它解决的不是确定性释放,而是尽量降低资源泄漏时的失控概率。判断它是否适合你的场景,关键要看三点:它比 finalizer 少踩哪些坑、显式关闭该怎么配合,以及哪些运行时边界根本不能依赖。
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 方法,用来取消那些还没有进入执行队列的清理任务。
这个设计的关键,不是“给对象加一个析构函数”,而是把两件事拆开:一边观察包装对象是否还活着,另一边只保留释放底层资源所需的最小状态。这样做的直接好处是,清理逻辑不必再接触原始包装对象,也就少了很多可达性相关的问题。
以类 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) })
}
然后让包装对象只持有状态指针,但状态对象不能再反向引用包装对象:
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 更不容易失手
过去很多库会用 runtime.SetFinalizer 给对象加最后一道保险,但 finalizer 的问题并不只是“调用时机不稳定”,更麻烦的是它的语义本身容易把对象生命周期搞复杂。
SetFinalizer(obj, fn) 会把 obj 本身交给 finalizer。垃圾回收器第一次发现对象不可达时,会移除 finalizer 并执行 fn(obj);而这一步又会让对象重新变得可达,因此对象通常还要等下一轮垃圾回收,才可能真正释放。只要对象之间还存在依赖关系,运行时就得处理先后顺序问题。
例如对象 A 引用 B,且二者都设置了 finalizer,那么运行时通常必须先跑 A 的 finalizer;如果几个对象形成了带 finalizer 的引用环,就可能因为没有合法的执行顺序而迟迟不能回收。这也是很多“明明对象已经不用了,资源却始终没放掉”的根源。
AddCleanup 的改进点在于,清理函数拿到的不是 ptr,而是独立的 arg。这意味着多个相互引用的包装对象一旦同时失去可达性,各自的 cleanup 可以按任意顺序执行,通常不会因为对象复活、依赖排序或引用环而直接卡死。它还支持为同一个对象绑定多个 cleanup,甚至绑定到同一块分配中的不同内部指针,这些都是 finalizer 模式里更容易出错的地方。
两者在调度上也不同。finalizer 由单个 goroutine 串行执行,一个慢任务就可能拖住后面的所有任务;cleanup 则可以和用户 goroutine 以及其他 cleanup 并发运行。这提高了吞吐,但也带来一个现实要求:清理函数不能依赖执行顺序,必须能独立完成,并正确处理并发。
所以,AddCleanup 的“更稳妥”并不是指它一定会执行,而是说它更不容易因为 finalizer 那套可达性语义而天然失效。
主路径仍然是 Close、Stop 和 KeepAlive
在生产代码里,资源管理的主路径没有变:API 仍应优先暴露 Close,并由调用方配合 defer 来确定释放时机。AddCleanup 只负责补上“有人忘了关”的异常路径,不能反过来当成主要释放方式。

显式关闭时,关键顺序是先 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 都还不能视为不可达。
这里的顺序不能写反。要是先关句柄,再调用 Stop,就会留下一个竞争窗口:cleanup 可能已经准备执行,而操作系统甚至可能把同一个文件描述符编号重新分配给别的资源,最终出现重复关闭或误关新资源的风险。
sync.Once 在这套模式里也不是可有可无。它保证无论是调用方重复执行 Close,还是显式关闭与兜底 cleanup 偶然撞上,底层关闭动作最多只会发生一次,错误值也稳定地保留首次关闭结果。
对调用侧来说,最佳实践依旧是:
defer f.Close()
这样才形成两层保护:Close 负责正常路径的确定性释放,AddCleanup 负责调用方遗忘关闭时的泄漏缓解。
最容易踩的两个可达性陷阱
虽然 AddCleanup 比 finalizer 更少踩坑,但它不是“注册完就安全”。真正麻烦的地方,仍然集中在可达性判断上。
陷阱一:cleanup 或 arg 反向指回 ptr
只要 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 只携带释放资源所需的最小信息。
陷阱二:对象过早变得不可达
另一个经常被低估的问题,是对象可能比你想象得更早“死掉”。在 Go 里,函数参数或方法接收者在最后一次被提及之后,就可能被运行时视为不可达。假设某个方法只取出 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 和业务代码互斥。只要清理函数和正常逻辑共享可变状态,仍然要用常规并发控制来保证正确性。
这些边界不能指望 AddCleanup 兜底
AddCleanup 再方便,也有一些边界必须提前接受:它不是事务提交器,不是缓冲刷新器,更不是程序退出时的收尾保障。
首先,cleanup 不保证在进程退出前一定运行,所以不能把它用于影响正确性的动作,例如刷新 bufio.Writer、提交事务、写审计日志,或者其他“程序结束前必须完成”的收尾步骤。这些动作必须放在显式关闭流程里完成。
其次,并不是所有对象都适合绑定 cleanup。零字节对象可能和其他对象共享地址;包级变量初始化期间创建的对象可能由链接器分配;这两类对象的 cleanup 都不保证执行。对于大约 16 字节以内且不含指针的微小对象,运行时还可能采用批量分配;如果同一批次里一直有对象存活,那么某个已经不可达的对象,其 cleanup 也可能被拖很久才触发。
再往下看,同一对象上的多个 cleanup 既没有顺序保证,也可能和其他任务并发执行。因此清理函数本身应当尽量短小、幂等并且线程安全,不能假设“另一个资源已经先释放”或者“另一个 cleanup 还没开始”。如果清理逻辑本身很耗时,更适合在 cleanup 内部再启动新的 goroutine,避免长时间阻塞运行时调度其他清理任务。
还有几个硬性限制也要注意:ptr 不能是 nil,也不能指向实验性 arena 中分配的对象,否则 AddCleanup 会 panic。若同一对象既注册了 finalizer,又注册了 cleanup,那么 cleanup 还要等 finalizer 执行结束、对象再次不可达且不再关联 finalizer 后,才有机会运行。工程上不建议混用这两套机制。
怎么给 AddCleanup 找准定位
runtime.AddCleanup 最重要的改进,是让清理函数只接触到底层资源参数,而不是被观察对象本身。这个分离降低了对象复活、引用环和依赖排序带来的复杂度,也让多个 cleanup 可以更自然地并发执行。
但资源生命周期管理的优先级并没有改变。显式 Close 与 defer 仍然负责确定性释放,Stop 用来避免重复清理,KeepAlive 用来划定对象必须存活到哪里,而 AddCleanup 只承担最后一道兜底。
把它理解成“泄漏保险”而不是“析构函数”,通常才是更贴近 Go 运行时实际行为的用法。









