• 从 unique 到 cleanups 和 weak:高效的新底层工具
  • 发布于 1个月前
  • 127 热度
    0 评论
在去年的关于 unique 包的博文①中,我们提到了当时处于提案审查阶段的一些新特性。现在很高兴与大家分享,从 Go 1.24 开始,所有开发者都可以使用这些新特性了!这些新特性包括 runtime.AddCleanup 函数(用于在对象不可达时排队运行函数)和 weak.Pointer 类型(安全指向对象而不阻止其被垃圾回收)。这两大特性结合起来足以构建你自己的 unique 包!让我们深入探讨这些特性的实用场景和使用方法。

注意:这些新特性是垃圾回收器的高级功能。如果还不熟悉基本的垃圾回收概念,强烈建议先阅读垃圾回收指南的简介部分②。

Cleanups(清理函数)
如果使用过终结器(finalizer),那么对 cleanup 的概念应该不陌生。终结器是通过调用 runtime.SetFinalizer 与已分配对象关联的函数,在对象变得不可达后由垃圾回收器调用。从高层次看,cleanup 的工作原理类似。我们通过一个使用内存映射文件的应用示例来说明 cleanup 的用法:
//go:build unix
// 堆代码 duidaima.com
type MemoryMappedFile struct {
    data []byte
}

func NewMemoryMappedFile(filename string) (*MemoryMappedFile, error) {
    f, err := os.Open(filename)
    if err != nil {
        returnnil, err
    }
    defer f.Close()

    // 获取文件信息(需要文件大小)
    fi, err := f.Stat()
    if err != nil {
        returnnil, err
    }

    // 提取文件描述符
    conn, err := f.SyscallConn()
    if err != nil {
        returnnil, 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 {
        returnnil, connErr
    }
    if err != nil {
        returnnil, err
    }
    mf := &MemoryMappedFile{data: data}
    cleanup := func(data []byte) {
        syscall.Munmap(data) // 忽略错误
    }
    runtime.AddCleanup(mf, cleanup, data)
    return mf, nil
}
内存映射文件的内容直接映射到内存中。通过这段代码,当 *MemoryMappedFile 不再被引用时,内存映射会被自动清理。

注意 runtime.AddCleanup 的三个参数:
1.要附加 cleanup 的变量地址
2.cleanup 函数本身

3.传给 cleanup 函数的参数


与 runtime.SetFinalizer 的关键区别在于:cleanup 函数的参数独立于附加对象。这个改变修复了终结器的若干问题。

终结器的痛点包括:
1.涉及引用循环时会导致内存泄漏
2.至少需要两次完整 GC 周期才能回收内存

3.对象复活(resurrection)问题


cleanup 通过不传递原始对象解决了这些问题:
1.对象涉及循环引用仍可被回收

2.内存可以立即回收


弱指针(Weak Pointers)
假设我们需要通过文件名去重内存映射文件。使用 weak.Pointer 类型可以安全地实现缓存:
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 {
                    returnnil, 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)
    }
}
该示例展示的关键特性:
1.弱指针可比较且具有稳定标识
2.支持为单个对象添加多个独立 cleanup

3.可实现通用缓存结构(见原文通用 Cache 结构示例)


注意事项与未来工作
使用这些特性时需注意:
1.cleanup 关联对象不可被 cleanup 函数或其参数引用
2.弱指针作为 map 键时,值不能引用键对象
3.非确定性行为依赖 GC 实现细节

4.测试具有挑战性


未来可能改进方向:
1.Ephemeron(短命对象)支持

2.直接追踪映射内存区域的 API


总结
runtime.AddCleanup 和 weak.Pointer 为 Go 带来了更精细的内存管理能力,但需要谨慎使用。大多数场景应通过标准库间接使用这些特性,而非直接操作。建议开发者仔细阅读更新后的垃圾回收指南③中的使用建议。这些特性的加入体现了 Go 团队在保持语言简洁性的同时,也在为高级使用场景提供必要的底层支持。正如文中所说:"这些是带有微妙语义的高级工具",但正确使用它们可以解决一些原本难以处理的问题。

文中链接
① https://go.dev/blog/unique
② https://go.dev/doc/gc-guide#Introduction
③ https://go.dev/doc/gc-guide
用户评论