Go 博客

全新独特包

Michael Knyszek
2024 年 8 月 27 日

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

驻留的简单实现

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

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 类似。在内部也存在一个全局映射 (一个快速通用的并发映射),Make 在该映射中查找提供的 value。但它也与 Intern 有两个重要的区别。首先,它接受任何可比较类型的 value。其次,它返回一个包装 value,一个 Handle[T],从中可以检索规范的 value。

这个 Handle[T] 是设计的关键。Handle[T] 具有以下属性:如果且仅当用于创建它们的 value 相等时,两个 Handle[T] value 才相等。此外,两个 Handle[T] value 的比较成本很低:它归结为指针比较。与比较两个长字符串相比,这要快得多!

到目前为止,这与您在普通 Go 代码中无法实现的内容相同。

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

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

一个实际的例子

那么,如何使用 unique.Make 呢?看看标准库中的 net/netip 包,它对类型为 addrDetail 的 value 进行驻留,这是 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 value 的比较效率更高,因为比较区域名称变成了简单的指针比较。

关于驻留字符串的说明

虽然 unique 包很有用,但 Make 确实不像 Intern 对字符串那样,因为 Handle[T] 是必需的,以防止字符串从内部映射中删除。这意味着您需要修改代码以保留句柄和字符串。

但字符串很特殊,因为尽管它们的行为像 value,但它们实际上在后台包含指针,正如我们之前提到的。这意味着我们可能会潜在地只对字符串的底层存储进行规范化,将 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 中构建内存高效的缓存 的未来一片光明!

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