《Go 语言高级编程》1.3 runtime.AddCleanup 与 weak 包

runtime.SetFinalizer 的老毛病是「复活对象、拖慢 GC、无法取消」,Go 1.24 用 runtime.AddCleanup 和 weak 包一起给出了新答案。本节实测清理回调真的执行、Cleanup.Stop 真的能取消、weak 指针在 GC 后真的返回 nil,并给出两者在 1.24 的 api 证据与典型缓存用法。

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 到底哪里不好

先把老方案的问题列清楚,才知道新方案在修什么:

问题SetFinalizerAddCleanup
对象复活可能复活,多花一轮 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.AddCleanupGo 1.24grep -h "^pkg runtime," api/go1.*.txt | grep AddCleanup → 仅 go1.24.txt
runtime.Cleanup / Cleanup.StopGo 1.24同上,#67535
weak.Make / weak.PointerGo 1.24grep -ln "^pkg weak," api/go1.*.txt → go1.24.txt(#67552)
unique.Make(对照,见 3.2)Go 1.23grep -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 与受限文件系统 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练