原文在这里

由 Michael Knyszek 发布于 2024年8月27日

Go 1.23 的标准库中新增了一个独特的包unique。这个包的目的是实现可比较值的规范化,也就是说,它允许你去重这些值,使它们指向一个唯一的、规范化的副本,同时在后台高效地管理这些规范化的副本。这个概念可能你已经熟悉了,称为“驻留(interning)”。让我们深入了解一下它的工作原理以及它的用途。

简单的驻留实现

从高层次来看,驻留非常简单。以下代码示例使用一个常规的映射来去重字符串。

var internPool map[string]string

// Intern 返回一个与 s 相等的字符串,但可能与之前传递给 Intern 的字符串共享存储空间。
func Intern(s string) string {
    pooled, ok := internPool[s]
    if !ok {
        // 复制字符串,以防它是某个更大字符串的一部分。
        // 如果驻留使用得当,这种情况应该很少发生。
        pooled = strings.Clone(s)
        internPool[pooled] = pooled
    }
    return pooled
}

当你构建大量可能是重复的字符串时(例如解析文本格式时),这段代码很有用。

这个实现非常简单,在某些情况下效果也不错,但它有一些问题:

  • 它从不从池中移除字符串。
  • 它不能安全地在多个 goroutine 中同时使用。
  • 它仅适用于字符串,尽管这个想法具有普遍性。

此外,这个实现中还有一个不太明显的遗漏。在底层,字符串是由指针和长度组成的不可变结构。当比较两个字符串时,如果指针不相等,则必须比较它们的内容以确定是否相等。但如果我们知道两个字符串是规范化的,那么只需检查它们的指针即可。

引入 unique 包

新的unique包引入了一个类似于Intern的函数 Make

它的工作方式与Intern差不多。内部也有一个全局映射(一个快速的通用并发映射),Make会在这个映射中查找提供的值。但它也有两个重要的区别。首先,它接受任何可比较类型的值。其次,它返回一个包装值Handle[T],可以通过它检索到规范化的值。

Handle[T]是设计的关键。两个Handle[T]值只有在用来创建它们的值相等时才相等。此外,比较两个Handle[T]值的成本很低:它只是指针比较。与比较两个长字符串相比,指针比较的成本要低得多!

到目前为止,这些功能在普通的 Go 代码中是可以实现的。

Handle[T]还有一个第二个用途:只要某个值的Handle[T]存在,映射就会保留该值的规范化副本。一旦所有映射到特定值的Handle[T]都不再存在,包就会将该内部映射条目标记为可删除,以便在不久的将来回收。这为何时从映射中移除条目设定了明确的政策:当规范化条目不再被使用时,垃圾收集器可以自由地清理它们。

如果你曾使用过 Lisp,这一切可能听起来很熟悉。Lisp 的符号是驻留的字符串,但它们不是字符串本身,所有符号的字符串值都保证在同一个池中。符号与字符串之间的关系类似于Handle[string]与字符串之间的关系。

一个真实世界的例子

那么,如何使用unique.Make呢?不妨看看标准库中的net/netip包,它驻留了addrDetail类型的值,这是netip.Addr结构的一部分。

下面是使用uniquenet/netip实际代码的简化版本。

// Addr 表示一个 IPv4 或 IPv6 地址(可能带有作用域地址区),类似于 net.IP 或 net.IPAddr。
type Addr struct {
    // 其他不相关的未导出字段...

    // 地址的详细信息,包装在一起并进行了规范化。
    z unique.Handle[addrDetail]
}

// addrDetail 表示地址是 IPv4 还是 IPv6,如果是 IPv6,还指定了该地址的区域名称。
type addrDetail struct {
    isV6   bool   // IPv4 为 false,IPv6 为 true。
    zoneV6 string // 如果 IsV6 为 true,可能不为空。
}

var z6noz = unique.Make(addrDetail{isV6: true})

// WithZone 返回一个与 ip 相同的 IP,但带有提供的区域。
// 如果区域为空,则移除区域。如果 ip 是 IPv4 地址,WithZone 是一个无操作并返回不变的 ip。
func (ip Addr) WithZone(zone string) Addr {
    if !ip.Is6() {
        return ip
    }
    if zone == "" {
        ip.z = z6noz
        return ip
    }
    ip.z = unique.Make(addrDetail{isV6: true, zoneV6: zone})
    return ip
}

由于许多 IP 地址可能使用相同的区域,并且该区域是它们标识的一部分,因此将它们规范化是很有意义的。区域的去重减少了每个netip.Addr的平均内存占用,而规范化的事实意味着netip.Addr值的比较更加高效,因为比较区域名称只需简单的指针比较。

关于字符串驻留的注解

虽然unique包很有用,但Make对于字符串的行为与Intern并不完全相同,因为Handle[T]是防止字符串从内部映射中删除的必要条件。这意味着你需要修改代码以保留句柄和字符串。

但字符串有一个特殊之处,尽管它们的行为像值,但它们实际上在底层包含指针,如前所述。这意味着我们可以潜在地仅规范化字符串的底层存储,将Handle[T]的细节隐藏在字符串内部。因此,未来仍有一种可能性,即所谓的“透明字符串驻留”,其中字符串可以不需要Handle[T]类型而被驻留,类似于Intern函数,但语义更接近Make

在此之前,unique.Make("my string").Value()是一个可能的解决方法。尽管未保留句柄会导致字符串从unique的内部映射中删除,但映射条目不会立即删除。实际上,条目不会在下次垃圾收集完成之前删除,因此在收集之间的时间段内,此解决方法仍允许一定程度的去重。

一些历史和对未来的展望

事实上,net/netip包自首次引入以来就驻留了区域字符串。它使用的驻留包是 go4.org/intern包的一个内部副本。与unique包类似,它有一个Value类型(看起来很像Handle[T],在泛型之前),并具有一旦其句柄不再被引用,内部映射中的条目就会被移除的显著特性。

但为了实现这种行为,它必须做一些不安全的事情。特别是,它对垃圾收集器的行为做出了一些假设,以在运行时之外实现弱指针。弱指针是一种不会阻止垃圾收集器回收变量的指针;当这种情况发生时,指针会自动变为 nil。实际上,弱指针也是unique包的核心抽象。

没错:在实现unique包时,我们为垃圾收集器添加了正式的弱指针支持。在经历了一系列令人遗憾的设计决策(如:弱指针是否应跟踪对象复活?答案是:不!)后,我们惊讶地发现这一切竟然如此简单明了。惊讶到弱指针现在已经成为一个公开提案

这项工作还促使我们重新审视终结器,最终提出了一个更易于使用且更高效的替代终结器的提案。随着对可比较值的哈希函数的开发,Go 在构建内存高效缓存方面的未来一片光明!


孟斯特

声明:本作品采用署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)进行许可,使用时请注明出处。

Author: mengbin

blog: mengbin

Github: mengbin92

cnblogs: 恋水无意

腾讯云开发者社区:孟斯特