1.3 runtime.AddCleanup 与 weak 包
「对象被回收时帮我做一件事」这个需求,Go 一直只能靠 runtime.SetFinalizer。但 SetFinalizer 的坑众所周知:它可能让对象在回收后复活,迫使运行时把它挪到另一轮 GC,拖慢整体回收;一个对象只能挂一个 finalizer,再挂就覆盖;而且它无法取消。Go 团队因此长期建议「非必要不用」。
Go 1.24 一次性补上了两个相关能力:runtime.AddCleanup 负责「不可达时的收尾」,weak 包负责「持有引用但不阻止回收」。它们是一对:很多 finalizer 的真实用途其实是「弱引用 + 清理」,现在可以分开表达。
本节要回答:
AddCleanup与weak各自解决什么问题、怎么用、哪个版本引入?结论:两者都是 Go 1.24 引入(api/go1.24.txt有据),AddCleanup可以挂多个、可以Stop、不会复活对象;weak.Pointer.Value()在对象不可达后返回nil,适合做「不阻止回收的缓存」。
1.3.1 SetFinalizer 到底哪里不好
先把老方案的问题列清楚,才知道新方案在修什么:
| 问题 | SetFinalizer | AddCleanup |
|---|---|---|
| 对象复活 | 可能复活,多花一轮 GC | 不复活 |
| 能否挂多个 | 只能一个,覆盖式 | 可以挂多个 |
| 能否取消 | 不能 | 能,Cleanup.Stop() |
| 回调参数 | 无 | 可携带一个 S 类型的实参 |
| 执行时机 | 下一轮 GC | 下一轮 GC(同样不保证及时) |
AddCleanup 的签名(go doc runtime.AddCleanup):
func AddCleanup[T, S any](ptr *T, cleanup func(S), arg S) Cleanup
它把「被观察的对象」(ptr)、「清理函数」和「要传给清理函数的参数」(arg)三者分开:清理函数不再需要捕获 ptr,因此不会因为闭包引用而让对象存活。这正是 finalizer 复活问题的根源之一。
1.3.2 实测:清理回调确实执行
package main
import (
"fmt"
"runtime"
"time"
)
type conn struct{ id int }
func main() {
done := make(chan string, 1)
c := &conn{id: 7}
runtime.AddCleanup(c, func(id int) {
done <- fmt.Sprintf("cleanup ran for conn %d", id)
}, c.id)
c = nil // 断开强引用
for i := 0; i < 3; i++ {
runtime.GC()
time.Sleep(20 * time.Millisecond)
select {
case msg := <-done:
fmt.Println(msg)
return
default:
}
}
fmt.Println("cleanup not observed yet")
}
实测输出(GOTOOLCHAIN=go1.27.0 go run .):
cleanup ran for conn 7
注意 arg 传的是 c.id(一个 int 的副本),而不是 c 本身。如果清理函数需要知道「清理的是哪个对象」,就应该这样把必要信息值拷贝进去,而不是捕获对象指针——后者会让对象永远不可达不了。
1.3.3 实测:Cleanup.Stop 能取消
AddCleanup 返回一个 Cleanup 值,调用 Stop() 即可取消。实测:
package main
import (
"fmt"
"runtime"
"time"
)
type res struct{ n int }
func main() {
ran := make(chan struct{}, 1)
r := &res{1}
c := runtime.AddCleanup(r, func(_ struct{}) { ran <- struct{}{} }, struct{}{})
c.Stop() // 取消清理
r = nil
for i := 0; i < 5; i++ {
runtime.GC()
time.Sleep(10 * time.Millisecond)
}
select {
case <-ran:
fmt.Println("cleanup ran (unexpected)")
default:
fmt.Println("cleanup did NOT run after Stop(): ok")
}
}
实测输出:
cleanup did NOT run after Stop(): ok
这是 SetFinalizer 完全做不到的:取消能力让清理注册变成可回滚的操作——比如一个连接被显式 Close() 后,就没必要再等 GC 去跑清理了。
1.3.4 weak 包:持引用但不阻止回收
weak 包只有两个导出符号(go doc weak):
func Make[T any](ptr *T) Pointer[T]
func (p Pointer[T]) Value() *T
weak.Make(obj) 返回一个弱引用;只要还有别的地方持有 obj 的强引用,Value() 就返回它;一旦强引用消失、对象被回收,Value() 就返回 nil。实测:
package main
import (
"fmt"
"runtime"
"weak"
)
type conn struct{ id int }
func main() {
obj := &conn{id: 42}
wp := weak.Make(obj)
fmt.Println("before GC, Value() != nil:", wp.Value() != nil)
obj = nil
for i := 0; i < 5; i++ {
runtime.GC()
if wp.Value() == nil {
break
}
}
fmt.Println("after GC, Value() == nil:", wp.Value() == nil)
}
实测输出:
before GC, Value() != nil: true
after GC, Value() == nil: true
1.3.5 版本归属(api 证据)
本节两个包都能在 /usr/local/go/api/go1.24.txt 里找到,这是最硬的证据:
| 符号 | 引入版本 | 核实命令与结果 |
|---|---|---|
runtime.AddCleanup | Go 1.24 | grep -h "^pkg runtime," api/go1.*.txt | grep AddCleanup → 仅 go1.24.txt |
runtime.Cleanup / Cleanup.Stop | Go 1.24 | 同上,#67535 |
weak.Make / weak.Pointer | Go 1.24 | grep -ln "^pkg weak," api/go1.*.txt → go1.24.txt(#67552) |
unique.Make(对照,见 3.2) | Go 1.23 | grep -ln "^pkg unique," api/go1.*.txt → go1.23.txt |
原始 api 行:
pkg runtime, func AddCleanup[$0 interface{}, $1 interface{}](*$0, func($1), $1) Cleanup #67535
pkg runtime, method (Cleanup) Stop() #67535
pkg weak, func Make[$0 interface{}](*$0) Pointer[$0] #67552
pkg weak, method (Pointer[$0]) Value() *$0 #67552
两个包在 1.26 与 1.27 上跑出的输出与 1.24 完全一致,没有行为变化,也没有实验开关——它们从 1.24 起就是正式 API,不需要 GOEXPERIMENT。
1.3.6 典型用法:不阻止回收的缓存
弱引用最自然的用途是「缓存」:缓存项不该因为被缓存而永远活着。实测一个用 weak.Pointer 做值的缓存:
cache := map[string]weak.Pointer[payload]{}
for i := 0; i < 3; i++ {
cache[fmt.Sprintf("key-%d", i)] = weak.Make(&payload{})
}
// ... 统计 Value() != nil 的条目
实测输出:
alive before GC: 3
alive after GC: 0
len(cache) unchanged: 3
三条信息很关键:
- 回收前 3 个条目都活着;
- GC 后全部
Value() == nil——对象被回收了,缓存没有阻止它; len(cache)仍是 3——弱引用本身不会自动从 map 里消失,清理失效条目是你自己的责任。
最后一点是工程上最容易忽略的:弱引用解决的是「不阻止回收」,不解决「自动淘汰」。缓存该有的过期/容量策略一样都不能少,否则 map 里会堆满 Value() == nil 的空壳。
1.3.7 三条使用纪律
| 纪律 | 原因 |
|---|---|
清理函数用 arg 传值,别捕获对象指针 | 捕获指针会让对象永远不可达不了,清理永不触发 |
别把 AddCleanup 当「析构函数」 | 执行时机不保证,程序退出时也不保证执行 |
| 弱引用不等于自动淘汰 | 失效条目仍占 map 槽位,需要自己清理 |
还有一条隐含前提:AddCleanup 的清理回调不保证在程序退出前一定运行。它只在 GC 回收该对象时触发。所以真正的资源释放(关闭文件、释放锁)必须走显式的 Close/defer,AddCleanup 只适合「兜底」。
1.3.8 小结
runtime.AddCleanup与weak包都在 Go 1.24 引入,api/go1.24.txt可查(#67535、#67552),无实验开关。AddCleanup相比SetFinalizer:不复活、可多个、可Stop、可携带参数。weak.Pointer.Value()在对象不可达后返回nil,适合做不阻止回收的缓存,但不会自动淘汰。- 两者的清理/回收时机都由 GC 决定,不能替代显式的资源管理。
到这里第 1 章结束。下一章转向标准库里新增的安全与密码学设施,先看 os.Root 如何在文件系统层面把「目录穿越」堵死。
阅读导航:上一节:1.2 Swiss Table map 与运行时改进 · 下一节:2.1 os.Root 与受限文件系统 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。