Go 博客
从唯一到清理和弱引用:提升效率的新底层工具
在去年关于 unique
包的博客文章中,我们曾暗示了一些当时尚处于评审阶段的新功能,我们很高兴地与大家分享,从 Go 1.24 开始,它们现在已对所有 Go 开发者可用。这些新功能是runtime.AddCleanup
函数,它会排队等待一个函数在对象不再可达时执行,以及weak.Pointer
类型,它安全地指向一个对象而不阻止其被垃圾回收。这两个功能结合起来,足以让您构建自己的 unique
包!让我们深入探讨这些功能为何有用,以及何时使用它们。
注意:这些新功能是垃圾回收器的先进功能。如果您还不熟悉基本的垃圾回收概念,我们强烈建议您阅读我们的垃圾回收器指南的引言。
清理
如果您曾经使用过 finalizer,那么清理的概念就会很熟悉。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 也永远不会运行,导致内存泄漏。Finalizers 还会显著延迟内存回收。回收已 finalizer 对象的内存至少需要两次完整的垃圾回收周期:一次确定它不可达,另一次确定在 finalizer 执行后它仍然不可达。
问题在于,finalizer 会复活它们所附加的对象。Finalizer 在对象不可达时运行,此时它被认为是“死亡”的。但是,由于 finalizer 是通过指向该对象的指针调用的,垃圾回收器必须阻止回收该对象的内存,而是必须为 finalizer 生成新的引用,使其再次变得可达,“存活”。这个引用甚至可能在 finalizer 返回后仍然存在,例如,如果 finalizer 将其写入全局变量或通过通道发送。对象复活是有问题的,因为它意味着该对象及其指向的所有内容,以及那些内容指向的所有内容,依此类推,都是可达的,即使它们本可以被垃圾回收。
我们通过不将原始对象传递给清理函数来解决这两个问题。首先,对象引用的值不需要由垃圾回收器特别保持可达,因此即使对象参与了循环,也可以被回收。其次,由于清理不需要该对象,因此可以立即回收其内存。
弱指针
回到我们的内存映射文件示例,假设我们注意到我们的程序经常一遍又一遍地映射相同的文件,来自不同的、互不了解的 goroutine。从内存角度来看,这没关系,因为所有这些映射都会共享物理内存,但会导致大量不必要的系统调用来映射和取消映射文件。如果每个 goroutine 只读取文件的一小部分,这尤其糟糕。
因此,让我们通过文件名对映射进行去重。(假设我们的程序仅读取映射,并且在创建后文件本身从未被修改或重命名。例如,对于系统字体文件,此类假设是合理的。)
我们可以维护一个从文件名到内存映射的映射,但随后就无法确定何时可以安全地从该映射中删除条目。如果我们不是因为映射条目本身会使内存映射文件对象保持活动状态,那么我们*几乎*可以使用清理功能。
弱指针解决了这个问题。弱指针是一种特殊的指针,垃圾回收器在决定对象是否可达时会忽略它。Go 1.24 的新弱指针类型 weak.Pointer
有一个 Value
方法,如果对象仍然可达,则返回一个真实指针,如果不可达,则返回 nil
。
如果我们改为维护一个*仅弱地*指向内存映射文件的映射,那么当没有人再使用该映射条目时,我们就可以清理它!让我们看看它是什么样的。
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)
}
}
这个例子有点复杂,但核心很简单。我们从一个全局并发映射开始,记录我们创建的所有映射文件。NewCachedMemoryMappedFile
在此映射中查找现有的映射文件,如果找不到,则创建并尝试插入新的映射文件。由于我们正在与其他插入操作竞争,这当然也可能失败,所以我们必须小心这一点,并重试。(此设计有一个缺陷,即我们可能会在竞争中浪费性地多次映射同一个文件,然后我们必须通过 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
的设计也很快就成形了。
致谢
我要感谢社区中所有在提案 issue 中贡献反馈并在功能可用时报告 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 map 的速度
博客索引