Go 1.23 标准库引入了一个名为 unique 的新包,旨在实现可比较值的规范化。简而言之,该包允许你对值进行去重,使其指向单个规范的唯一副本,并在底层有效管理这些规范副本。你可能已经熟悉这个概念,称为“字符串驻留”。本文将深入探讨 unique 包的工作原理及其应用价值。

字符串驻留的简单实现

在较高层次上,字符串驻留非常简单。以下代码示例使用常规映射对字符串进行去重:

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 包引入了一个名为 Make 的函数,类似于 Intern

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

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

到目前为止,这在普通的 Go 代码中都能做到。

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

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

真实案例分析

那么,如何使用 unique.Make 呢?看看标准库中的 net/netip 包,它对类型为 addrDetail 的值进行字符串驻留,addrDetailnetip.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 中构建内存高效缓存的未来一片光明!