Go 博客
新的 unique 包
Go 1.23 的标准库现在包含了 新的 unique
包。这个包的目的是实现可比较值的规范化(canonicalization)。换句话说,这个包允许您去除重复值,使它们指向一个唯一的规范副本,同时在内部高效地管理这些规范副本。您可能已经熟悉这个概念,它被称为“interning”。让我们深入了解它是如何工作的以及为何有用。
interning 的简单实现
从高层面看,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
}
这对于构建大量可能重复的字符串时很有用,例如解析文本格式时。
这个实现超级简单,对于某些情况来说效果不错,但它有一些问题
- 它从不从池中移除字符串。
- 它不能被多个 goroutines 并发安全地使用。
- 它只适用于字符串,尽管这个想法非常普遍。
这个实现还有一个被忽略的机会,而且很微妙。在底层,字符串是不可变结构,由一个指针和长度组成。当比较两个字符串时,如果指针不相等,那么我们必须比较它们的内容来确定是否相等。但是如果我们知道两个字符串已经规范化(canonicalized),那么只检查它们的指针就足够了。
引入 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 符号 (symbols) 是 interned 字符串,但它们本身不是字符串,并且所有符号的字符串值都保证在同一个池中。符号和字符串之间的这种关系,类似于 Handle[string]
和 string
之间的关系。
一个真实的例子
那么,如何使用 unique.Make
呢?标准库中的 net/netip
包就是一个很好的例子,它对类型为 addrDetail
的值进行了 interning,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 地址很可能使用相同的区域(zone),并且这个区域是它们标识的一部分,因此对其进行规范化非常有意义。对区域进行去重减少了每个 netip.Addr
的平均内存占用,而它们被规范化这一事实意味着 netip.Addr
值比较起来更高效,因为比较区域名称变成了简单的指针比较。
关于 interning 字符串的附注
虽然 unique
包很有用,但必须承认,对于字符串来说,Make
与 Intern
不完全相同,因为需要 Handle[T]
来防止字符串从内部 map 中删除。这意味着您需要修改代码来同时保留 handles 和字符串。
但字符串是特殊的,尽管它们表现得像值,但如我们之前提到的,它们在底层实际上包含指针。这意味着我们有可能只对字符串的底层存储进行规范化,将 Handle[T]
的细节隐藏在字符串本身内部。因此,未来仍然有我称之为透明字符串 interning 的空间,在这种机制下,字符串可以在没有 Handle[T]
类型的情况下进行 interning,类似于 Intern
函数,但语义更接近 Make
。
同时,unique.Make("my string").Value()
是一个可能的临时解决方案。尽管未能保留 handle 会导致字符串可以从 unique
的内部 map 中删除,但 map 条目不会立即被删除。实际上,条目至少要等到下一次垃圾回收完成才会删除,因此这个临时解决方案在垃圾回收之间的时间段内仍然可以在一定程度上实现去重。
一些历史,以及展望未来
事实是,net/netip
包从最初引入时就对区域字符串进行了 interning。它使用的 interning 包是 go4.org/intern 包的内部副本。与 unique
包一样,它有一个 Value
类型(看起来很像泛型之前的 Handle[T]
),其显著特点是:一旦其 handles 不再被引用,内部 map 中的条目就会被移除。
但为了实现这种行为,它必须做一些不安全的事情。特别是,它对垃圾收集器的行为做了一些假设,以便在运行时外部实现 弱引用 (weak pointers)。弱引用是一种不会阻止垃圾收集器回收变量的指针;当回收发生时,该指针会自动变成 nil。巧合的是,弱引用也是 unique
包的核心抽象。
没错:在实现 unique
包的同时,我们在垃圾收集器中添加了适当的弱引用支持。在经历了一系列与弱引用相关的令人遗憾的设计决策雷区之后(比如,弱引用是否应该跟踪对象复活?不!),我们惊讶地发现这一切竟然如此简单明了。这种惊讶程度足以让弱引用现在成为一个公开提案。
这项工作还促使我们重新审视 finalizers,从而提出了另一个更容易使用且更高效的finalizers 替代方案的提案。随着可比较值的哈希函数也即将推出,Go 中构建内存高效缓存的未来一片光明!
下一篇文章:Go 1.23 及更高版本中的遥测
上一篇文章:遍历函数类型
博客索引