Go 博客
从 unique 到 cleanup 再到 weak:提升效率的新底层工具
在去年关于 unique
包的博文中,我们提到了当时正在进行提案评审的一些新特性,我们很高兴分享这些特性在 Go 1.24 中已对所有 Go 开发者开放。这些新特性包括 runtime.AddCleanup
函数,它在对象不再可达时将一个函数排队运行;以及 weak.Pointer
类型,它安全地指向一个对象,同时又不阻止该对象被垃圾回收。这两个特性结合起来,足以构建您自己的 unique
包!让我们深入探讨这些特性为何有用以及何时使用它们。
注意:这些新特性是垃圾回收器的高级特性。如果您对基本的垃圾回收概念尚不熟悉,我们强烈建议阅读我们的垃圾回收器指南的引言部分。
清理函数
如果您曾经使用过 finalizer(终结器),那么 cleanup(清理函数)的概念就会很熟悉。Finalizer 是一种函数,通过调用 runtime.SetFinalizer
与分配的对象关联,在对象变得不可达后的某个时间由垃圾回收器调用。从高层来看,清理函数的工作方式相同。
让我们考虑一个使用内存映射文件的应用程序,看看清理函数如何提供帮助。
//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()
// Get the file's info; we need its size.
fi, err := f.Stat()
if err != nil {
return nil, err
}
// Extract the file descriptor.
conn, err := f.SyscallConn()
if err != nil {
return nil, err
}
var data []byte
connErr := conn.Control(func(fd uintptr) {
// Create a memory mapping backed by this file.
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) // ignore error
}
runtime.AddCleanup(mf, cleanup, data)
return mf, nil
}
内存映射文件将其内容映射到内存,在本例中是字节切片的底层数据。借助于操作系统的一些“魔力”,对字节切片的读写直接访问文件的内容。通过这段代码,我们可以传递 *MemoryMappedFile
,当它不再被引用时,我们创建的内存映射将被清理。
注意,runtime.AddCleanup
接受三个参数:要附加清理函数的变量地址、清理函数本身以及清理函数的一个参数。这个函数与 runtime.SetFinalizer
的一个关键区别在于,清理函数接受的参数与我们附加清理函数的对象不同。这一改变解决了 finalizer 的一些问题。
finalizer 很难正确使用已不是秘密。例如,附加了 finalizer 的对象不能参与任何引用循环(即使是自引用也不行!),否则对象将永远不会被回收,finalizer 也永远不会运行,导致内存泄漏。Finalizer 还会显著延迟内存回收。回收一个带 finalizer 的对象内存至少需要两个完整的垃圾回收周期:一个周期确定它不可达,另一个周期在 finalizer 执行后确定它仍然不可达。
问题在于 finalizer 会复活它们附加的对象。Finalizer 直到对象不可达时才会运行,此时对象被视为“死亡”。但是,由于 finalizer 是使用对象的指针调用的,垃圾回收器必须阻止回收该对象的内存,而是必须为 finalizer 生成一个新的引用,使其再次变得可达,或“存活”。该引用甚至可能在 finalizer 返回后仍然存在,例如如果 finalizer 将其写入全局变量或通过通道发送。对象复活是有问题的,因为它意味着该对象及其指向的一切,以及那些对象指向的一切,依此类推,都变得可达,即使在正常情况下它们本应被作为垃圾回收。
通过不将原始对象传递给清理函数,我们解决了这两个问题。首先,对象引用的值不需要被垃圾回收器特殊保持可达,因此即使对象参与循环,它仍然可以被回收。其次,由于清理不需要该对象,它的内存可以立即被回收。
弱指针
回到我们的内存映射文件示例,假设我们注意到程序经常从彼此 unaware 的不同 goroutine 中一次又一次地映射相同的文件。从内存角度来看这没问题,因为所有这些映射将共享物理内存,但这会导致大量不必要的系统调用来映射和解除映射文件。如果每个 goroutine 只读取每个文件的一小部分,情况尤其糟糕。
因此,让我们按文件名对映射进行去重。(假设我们的程序只从映射中读取,并且文件本身一旦创建后永不修改或重命名。例如,对于系统字体文件来说,这样的假设是合理的。)
我们可以维护一个从文件名到内存映射的 map,但这会使何时安全地从该 map 中删除条目变得不清楚。我们几乎可以使用清理函数,如果不是因为 map 条目本身会保持内存映射文件对象“存活”的话。
弱指针解决了这个问题。弱指针是一种特殊的指针,垃圾回收器在判断对象是否可达时会忽略它。Go 1.24 新增的弱指针类型 weak.Pointer
有一个 Value
方法,如果对象仍然可达,则返回一个实际的指针,如果不可达,则返回 nil
。
如果我们转而维护一个仅弱引用内存映射文件的 map,当没有人再使用它时,我们就可以清理该 map 条目!让我们看看这是怎么样的。
var cache sync.Map // map[string]weak.Pointer[MemoryMappedFile]
func NewCachedMemoryMappedFile(filename string) (*MemoryMappedFile, error) {
var newFile *MemoryMappedFile
for {
// Try to load an existing value out of the cache.
value, ok := cache.Load(filename)
if !ok {
// No value found. Create a new mapped file if needed.
if newFile == nil {
var err error
newFile, err = NewMemoryMappedFile(filename)
if err != nil {
return nil, err
}
}
// Try to install the new mapped file.
wp := weak.Make(newFile)
var loaded bool
value, loaded = cache.LoadOrStore(filename, wp)
if !loaded {
runtime.AddCleanup(newFile, func(filename string) {
// Only delete if the weak pointer is equal. If it's not, someone
// else already deleted the entry and installed a new mapped file.
cache.CompareAndDelete(filename, wp)
}, filename)
return newFile, nil
}
// Someone got to installing the file before us.
//
// If it's still there when we check in a moment, we'll discard newFile
// and it'll get cleaned up by garbage collector.
}
// See if our cache entry is valid.
if mf := value.(weak.Pointer[MemoryMappedFile]).Value(); mf != nil {
return mf, nil
}
// Discovered a nil entry awaiting cleanup. Eagerly delete it.
cache.CompareAndDelete(filename, value)
}
}
这个例子有点复杂,但要点很简单。我们从一个包含所有已映射文件的全局并发 map 开始。NewCachedMemoryMappedFile
查询此 map 以查找现有映射文件,如果失败,则创建并尝试插入新的映射文件。由于我们与其他插入操作存在竞争,这当然也可能失败,因此我们也需要对此小心并重试。(这种设计有一个缺陷,即在竞争中我们可能会浪费地多次映射同一个文件,并且必须通过 NewMemoryMappedFile
添加的清理函数将其丢弃。大多数时候这可能不是大问题。修复它留给读者作为练习。)
让我们看看这段代码利用的弱指针和清理函数的一些有用特性。
首先,注意弱指针是可比较的。不仅如此,弱指针具有稳定且独立的身份,即使它们指向的对象早已不存在,该身份仍然保留。这就是为什么清理函数调用 sync.Map
的 CompareAndDelete
是安全的,该方法会比较 weak.Pointer
,这也是这段代码能够工作的关键原因。
其次,观察到我们可以向单个 MemoryMappedFile
对象添加多个独立的清理函数。这使我们可以以可组合的方式使用清理函数,并利用它们构建通用数据结构。在这个特定的例子中,将 NewCachedMemoryMappedFile
与 NewMemoryMappedFile
结合起来并让它们共享一个清理函数可能更有效。然而,我们上面编写的代码的优势在于它可以以泛型的方式重写!
type Cache[K comparable, V any] struct {
create func(K) (*V, error)
m sync.Map
}
func NewCache[K comparable, V any](create func(K) (*V, error)) *Cache[K, V] {
return &Cache[K, V]{create: create}
}
func (c *Cache[K, V]) Get(key K) (*V, error) {
var newValue *V
for {
// Try to load an existing value out of the cache.
value, ok := cache.Load(key)
if !ok {
// No value found. Create a new mapped file if needed.
if newValue == nil {
var err error
newValue, err = c.create(key)
if err != nil {
return nil, err
}
}
// Try to install the new mapped file.
wp := weak.Make(newValue)
var loaded bool
value, loaded = cache.LoadOrStore(key, wp)
if !loaded {
runtime.AddCleanup(newValue, func(key K) {
// Only delete if the weak pointer is equal. If it's not, someone
// else already deleted the entry and installed a new mapped file.
cache.CompareAndDelete(key, wp)
}, key)
return newValue, nil
}
}
// See if our cache entry is valid.
if mf := value.(weak.Pointer[V]).Value(); mf != nil {
return mf, nil
}
// Discovered a nil entry awaiting cleanup. Eagerly delete it.
cache.CompareAndDelete(key, value)
}
}
注意事项和未来工作
尽管我们尽了最大努力,清理函数和弱指针仍然容易出错。为了指导那些考虑使用 finalizer、清理函数和弱指针的人,我们最近更新了垃圾回收器指南,其中包含一些关于使用这些特性的建议。下次您打算使用它们时,请先阅读该指南,同时也要仔细考虑您是否真的需要使用它们。这些是具有微妙语义的高级工具,正如指南所说,大多数 Go 代码通过间接方式从这些特性中受益,而不是直接使用它们。坚持在这些特性发挥作用的用例中使用它们,您就会没问题。
目前,我们将指出一些您更可能遇到的问题。
首先,清理函数附加到的对象不能从清理函数(作为捕获变量)或清理函数的参数中可达。这两种情况都会导致清理函数永远不会执行。(在清理函数参数恰好是传递给 runtime.AddCleanup
的指针的特殊情况下,runtime.AddCleanup
将会 panic,以此向调用者发出信号,表示他们不应像使用 finalizer 那样使用清理函数。)
其次,当弱指针用作 map 键时,弱引用的对象不能从对应的 map 值中可达,否则该对象将继续保持存活。在深入阅读弱指针博文时这可能看起来很明显,但这是一个容易被忽视的微妙之处。这个问题启发了短命对象(Ephemeron)的整个概念来解决它,这是未来的一个潜在方向。
第三,使用清理函数的一个常见模式是需要一个包装对象,就像我们在 MemoryMappedFile
示例中看到的那样。在这种特殊情况下,您可以想象垃圾回收器直接跟踪映射的内存区域并传递内部的 []byte
。这样的功能可能是未来的工作,并且最近已经提出了一个相关的 API。
最后,弱指针和清理函数本质上都是非确定性的,它们的行为与垃圾回收器的设计和动态密切相关。清理函数的文档甚至允许垃圾回收器根本不运行清理函数。有效测试使用它们的代码可能很棘手,但这是可能的。
为什么是现在?
弱指针作为 Go 的一个特性几乎从一开始就被提出,但多年来 Go 团队并未优先考虑它们。一个原因是它们很微妙,弱指针的设计空间充满了各种决策陷阱,可能使其更加难以使用。另一个原因是弱指针是一种小众工具,同时会增加语言的复杂性。我们已经有使用 SetFinalizer
可能有多痛苦的经验。但有一些有用的程序没有弱指针就无法表达,而 unique
包及其存在的原因确实强调了这一点。
结合泛型、从 finalizer 中获得的经验以及其他语言(如 C# 和 Java)团队所做的出色工作带来的见解,弱指针和清理函数的设计很快就形成了。将弱指针与 finalizer 一起使用的愿望提出了额外的问题,因此 runtime.AddCleanup
的设计也很快完善了。
致谢
我要感谢社区中所有对提案问题提供反馈并在特性可用时提交 bug 的人。我还要感谢 David Chase 与我一起彻底思考了弱指针的语义,并感谢他、Russ Cox 和 Austin Clements 在 runtime.AddCleanup
设计上的帮助。感谢 Carlos Amedee 为 runtime.AddCleanup
的实现、完善以及在 Go 1.24 中落地所做的工作。最后,我要感谢 Carlos Amedee 和 Ian Lance Taylor 在 Go 1.25 中用 runtime.AddCleanup
替换标准库中所有 runtime.SetFinalizer
所做的工作。
下一篇文章:防遍历文件 API
上一篇文章:使用 Swiss Tables 加速 Go maps
博客索引