Go 博客

新通用包

Michael Knyszek
2024 年 8 月 27 日

Go 1.23 的标准库现已包含 新的 unique。该包的目的是实现可比较值的规范化。换句话说,此包允许您对值进行去重,使它们指向单个、规范的、唯一的副本,同时在后台高效地管理这些规范副本。您可能已经熟悉这个概念,称为 “字符串驻留”(interning)。让我们深入了解一下它的工作原理以及为什么它很有用。

字符串驻留的简单实现

从高层来看,字符串驻留非常简单。看看下面的代码示例,它仅使用普通的 map 来对字符串进行去重。

var internPool map[string]string

// Intern returns a string that is equal to s but that may share storage with
// a string previously passed to Intern.
func Intern(s string) string {
    pooled, ok := internPool[s]
    if !ok {
        // Clone the string in case it's part of some much bigger string.
        // This should be rare, if interning is being used well.
        pooled = strings.Clone(s)
        internPool[pooled] = pooled
    }
    return pooled
}

这在您构建大量可能重复的字符串时非常有用,例如在解析文本格式时。

这种实现非常简单,在某些情况下效果足够好,但它存在一些问题:

  • 它从不从池中移除字符串。
  • 它不能被多个 goroutine 安全地并发使用。
  • 它只适用于字符串,尽管这个概念相当通用。

此实现还存在一个被忽略的机会,而且很微妙。在后台,字符串是不可变的结构,由指针和长度组成。在比较两个字符串时,如果指针不相等,那么我们必须比较它们的内容来确定相等性。但如果我们知道两个字符串已经规范化,那么仅检查它们的指针就足够了。

引入 unique

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

它的工作方式与 Intern 大致相同。内部也有一个全局 map(一个快速的通用并发 map),Make 在该 map 中查找提供的值。但它与 Intern 的区别在于两个重要方面。首先,它接受任何可比类型的值。其次,它返回一个包装值,即 Handle[T],可以从中检索到规范值。

这个 Handle[T] 是设计的关键。一个 Handle[T] 具有这样的属性:两个 Handle[T] 值相等当且仅当用于创建它们的值相等。更重要的是,比较两个 Handle[T] 值是廉价的:它归结为指针比较。与比较两个长字符串相比,这要便宜一个数量级!

到目前为止,这并不是你在普通 Go 代码中做不到的。

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

如果您以前使用过 Lisp,这一切听起来可能都有些熟悉。Lisp 的符号是驻留的字符串,但不是字符串本身,并且保证所有符号的字符串值都在同一个池中。符号和字符串之间的这种关系类似于 Handle[string]string 之间的关系。

真实世界示例

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

下面是 net/netip 中使用 unique 的实际代码的节选。

// Addr represents an IPv4 or IPv6 address (with or without a scoped
// addressing zone), similar to net.IP or net.IPAddr.
type Addr struct {
    // Other irrelevant unexported fields...

    // Details about the address, wrapped up together and canonicalized.
    z unique.Handle[addrDetail]
}

// addrDetail indicates whether the address is IPv4 or IPv6, and if IPv6,
// specifies the zone name for the address.
type addrDetail struct {
    isV6   bool   // IPv4 is false, IPv6 is true.
    zoneV6 string // May be != "" if IsV6 is true.
}

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

// WithZone returns an IP that's the same as ip but with the provided
// zone. If zone is empty, the zone is removed. If ip is an IPv4
// address, WithZone is a no-op and returns ip unchanged.
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] 来防止字符串从内部 map 中删除。这意味着您需要修改代码以同时保留句柄和字符串。

但字符串很特别,因为尽管它们表现得像值,但实际上它们在底层包含指针,正如我们之前提到的。这意味着我们有可能仅规范化字符串的底层存储,将 Handle[T] 的细节隐藏在字符串本身中。因此,未来仍然存在我称之为*透明字符串驻留*的空间,其中字符串可以在没有 Handle[T] 类型的情况下进行驻留,类似于 Intern 函数,但语义更接近 Make

在此期间,unique.Make("my string").Value() 是一种可能的解决方法。即使未能保留句柄会导致字符串从 unique 的内部 map 中删除,但 map 条目不会立即删除。实际上,条目至少在下一次垃圾回收完成之前不会被删除,因此这种解决方法在两次回收之间的周期内仍然允许一定程度的去重。

一些历史,以及对未来的展望

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

但为了实现这种行为,它必须做一些不安全的事情。特别是,它对垃圾回收器的行为做了一些假设来实现弱指针(weak pointers),这些弱指针独立于运行时。弱指针是一种不会阻止垃圾回收器回收变量的指针;当这种情况发生时,指针会自动变为 nil。恰好,弱指针*也是* unique 包底层的主要抽象。

没错:在实现 unique 包的过程中,我们为垃圾回收器添加了对弱指针的正式支持。在经历(与弱指针相关的)一系列令人遗憾的设计决策(例如,弱指针是否应跟踪对象复活?不行!)之后,我们对这一切竟然如此简单和直接感到惊讶。惊讶到足以使弱指针成为一项公开提案

这项工作还促使我们重新审视 finalizers,从而提出了另一项关于更易于使用和更高效的finalizers 替代方案的提案。随着用于可比值的哈希函数也即将到来,在 Go 中构建内存高效缓存的未来是光明的!

下一篇文章:Go 1.23 及更高版本中的遥测
上一篇文章:函数类型的范围迭代
博客索引