原文在这里。
由 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结构的一部分。
下面是使用unique
的net/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: 恋水无意
腾讯云开发者社区:孟斯特