原文《From unique to cleanups and weak: new low-level tools for efficiency》
链接:https://go.dev/blog/cleanups-and-weak
以下翻译来自 DeepSeek
Michael Knyszek
2025年3月6日
去年,我们在一篇关于 unique 包的博文中,提到了当时尚处于提案阶段的一些新特性。现在,我们很高兴与大家分享,从 Go 1.24 开始,所有开发者都可以使用这些新特性了!它们包括 runtime.AddCleanup 函数(用于在对象不可达时排队运行函数)和 weak.Pointer 类型(安全地指向对象而不阻止其被垃圾回收)。这两大特性结合起来,足以让你构建自己的 unique 包。下面,我们就来深入探讨这些新工具的实际应用场景与使用方法。
注意:这些新特性是垃圾回收器的高级功能。如果你还不熟悉基本的垃圾回收概念,强烈建议先阅读垃圾回收指南的简介部分。
Cleanups(清理函数)
如果你使用过终结器(finalizer),那么对 cleanup 的概念应该不会陌生。终结器是通过调用 runtime.SetFinalizer 与已分配对象关联的函数,在对象变得不可达后由垃圾回收器调用。从高层次看,cleanup 的工作原理与此类似。
我们通过一个使用内存映射文件的应用示例,来具体说明 cleanup 的用法。想象一下,你需要高效读取大文件,但又不想一次性将所有内容加载到内存中,内存映射文件就是一个理想的方案:
//go:build unix
type MemoryMappedFile struct {
data []byte
}
func NewMemoryMappedFile(filename string) (*MemoryMappedFile, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
// 获取文件信息(需要文件大小)
fi, err := f.Stat()
if err != nil {
return nil, err
}
// 提取文件描述符
conn, err := f.SyscallConn()
if err != nil {
return nil, err
}
var data []byte
connErr := conn.Control(func(fd uintptr) {
// 创建由该文件支持的内存映射
data, err = syscall.Mmap(int(fd), 0, int(fi.Size()), syscall.PROT_READ, syscall.MAP_SHARED)
})
if connErr != nil {
return nil, connErr
}
if err != nil {
return nil, err
}
mf := &MemoryMappedFile{data: data}
cleanup := func(data []byte) {
syscall.Munmap(data) // 忽略错误
}
runtime.AddCleanup(mf, cleanup, data)
return mf, nil
}
这段代码将文件内容直接映射到内存中。关键是,当 *MemoryMappedFile 实例不再被引用时,与之关联的内存映射区域会自动通过 syscall.Munmap 进行清理,从而释放系统资源。这省去了手动调用清理的麻烦,也避免了资源泄漏的风险。
注意 runtime.AddCleanup 的三个参数:
- 要附加 cleanup 的变量地址(本例中是
mf)
- cleanup 函数本身(本例中的匿名函数)
- 传给 cleanup 函数的参数(本例中是
data)
这与 runtime.SetFinalizer 有一个关键区别:cleanup 函数的参数独立于被附加的对象。正是这个设计上的改变,修复了传统终结器的若干痛点。
终结器有哪些让人头疼的问题呢?
- 当涉及引用循环时,可能会导致内存泄漏。
- 至少需要两次完整的 GC 周期才能回收内存。
- 存在对象复活(resurrection)的问题。
而 cleanup 通过不向清理函数传递原始对象的方式,巧妙地解决了前两个问题:
- 即使对象涉及循环引用,仍然可以被回收。
- 内存可以在对象不可达后立即被标记为可回收。
弱指针(Weak Pointers)
现在,假设我们的场景更复杂一些:需要通过文件名对内存映射文件进行去重缓存。我们希望在内存中只保留一份相同文件的映射,并在没有任何外部引用时自动清理缓存条目。这时,weak.Pointer 类型就派上用场了。
weak.Pointer[T] 提供了一种“弱引用”,它指向一个类型为 T 的对象,但不会阻止该对象被垃圾回收器回收。当对象被回收后,弱引用的 Value() 方法会返回 nil。下面我们来看一个缓存示例:
var cache sync.Map // map[string]weak.Pointer[MemoryMappedFile]
func NewCachedMemoryMappedFile(filename string) (*MemoryMappedFile, error) {
var newFile *MemoryMappedFile
for {
// 尝试从缓存加载
value, ok := cache.Load(filename)
if !ok {
// 创建新映射文件
if newFile == nil {
var err error
newFile, err = NewMemoryMappedFile(filename)
if err != nil {
return nil, err
}
}
// 尝试安装新映射文件到缓存
wp := weak.Make(newFile)
var loaded bool
value, loaded = cache.LoadOrStore(filename, wp)
if !loaded {
runtime.AddCleanup(newFile, func(filename string) {
cache.CompareAndDelete(filename, wp)
}, filename)
return newFile, nil
}
}
// 检查缓存条目有效性
if mf := value.(weak.Pointer[MemoryMappedFile]).Value(); mf != nil {
return mf, nil
}
// 发现待清理的空条目(原对象已被回收)
cache.CompareAndDelete(filename, value)
}
}
这个示例展示了几个关键特性:
- 弱指针可比较且有稳定标识:这使得它们可以作为
sync.Map 的值安全存储和比较。
- 支持为单个对象添加多个独立 cleanup:我们在创建新文件对象时,不仅为其内存映射添加了 cleanup,还为缓存条目清理添加了另一个 cleanup。这两个清理操作是独立的。
- 可实现通用缓存结构:弱指针是构建“一旦没有强引用就自动失效”的缓存的基础构件。原文中还提供了一个更通用的
Cache 结构示例,其思路与此类似。
注意事项与未来工作
虽然 runtime.AddCleanup 和 weak.Pointer 功能强大,但使用时必须保持谨慎,因为它们是与 GC 深度交互的高级工具。
主要注意事项:
- 避免循环引用:Cleanup 关联的对象(第一个参数)绝不能被 cleanup 函数本身或其参数所引用,否则会创建循环,导致内存泄漏。
- Map 键的独立性:当弱指针作为 map 的键时,map 的值不能引用键所指向的对象,理由同上。
- 非确定性:Cleanup 函数和弱指针失效的精确时机依赖于垃圾回收器的实现细节,是非确定性的。
- 测试挑战:由于非确定性,编写可靠的单元测试来验证 cleanup 行为会比较困难。
未来可能的方向:
- Ephemeron(短命对象)支持:这是一种更复杂的弱引用形式,键的存活依赖于值,未来可能会在标准库中提供。
- 直接内存区域追踪 API:为像内存映射这样的场景提供更直接、专用的 API。
总结
runtime.AddCleanup 和 weak.Pointer 的引入,为 Go 语言带来了更精细、更安全的底层内存管理能力。它们解决了终结器的一些固有缺陷,并开启了构建高级资源管理结构和缓存的新可能。
然而,正如 Go 团队所强调的,“这些都是带有微妙语义的高级工具”。对于绝大多数应用场景,你更应该通过标准库或设计良好的第三方库来间接使用这些特性,而不是直接操作。建议所有打算使用这些功能的开发者,都仔细阅读更新后的垃圾回收指南中的详细说明和建议。
这些特性的加入,体现了 Go 在保持语言简洁性和开发效率的同时,也在持续为高级用例和性能关键场景提供必要的底层支持。正确理解并运用它们,可以帮助你解决一些以往在 Go 中难以优雅处理的问题。
文中链接