Go 1.23 中的新包 unique
Go 的 unique
包提供了一系列工具,用于通过称为 驻留(或 规范化)的过程来优化“可比较的值”的管理。驻留是一种将多个相同的值(例如内容相同的字符串或结构体)合并为唯一副本的机制。通过这种方式,所有相同的值都共享同一块内存,从而显著减少内存使用并加快相等性比较的速度。
Michael Knyszek 在 Go 官方博客 中详细介绍了这个包,并讨论了开发过程中引入的一些新概念,例如弱指针和终结器的替代方案。
简单的驻留实现
从宏观上看,驻留的实现非常简单。以下代码展示了如何使用 Go 的基本 Map 来对字符串进行去重:
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 中安全地使用。
- 仅限于字符串:该思想实际上可以推广到其他类型。
- 优化不足:在比较字符串时,如果指针不相等,Go 需要比较字符串的内容。而驻留允许我们通过比较指针来快速判断相等性。
unique
包的引入
Go 1.23 引入的 unique
包提供了一个类似于 Intern
的函数 Make
。Make
在内部使用一个全局 Map(一个快速的泛型并发 Map)来查找值。与之前的简单实现不同,Make
有两个显著的区别:
- 支持泛型:它可以接受任何可比较的类型,而不仅限于字符串。
- Handle[T]:它返回一个
Handle[T]
,可以通过它检索规范化的值。
Handle[T]
的关键作用
Handle[T]
是该包设计的核心。两个 Handle[T]
只有在用相等的值创建时才相等。其优势在于:两个 Handle[T]
的比较只需进行指针比较,而不用比较具体的内容,这大大提高了效率。
此外,只要 Map 中存在某个值的 Handle[T]
,该值的规范化副本就会被保留。一旦所有指向特定值的 Handle[T]
消失,该值就可以被垃圾回收器移除。
如果你曾接触过 Lisp,这可能会让你想起 Lisp 中的符号系统。Lisp 中的符号类似于驻留的字符串,但符号本身并不是字符串。Handle[string]
与 string
的关系就像 Lisp 中符号与字符串的关系。
实际应用示例
Go 标准库中的 net/netip
包使用 unique
包对 addrDetail
类型的值进行了驻留。以下是该代码的简化版本:
type Addr struct {
// 地址的详细信息,进行了规范化存储。
z unique.Handle[addrDetail]
}
type addrDetail struct {
isV6 bool // 如果是 IPv6 则为 true,IPv4 则为 false。
zoneV6 string // 如果是 IPv6,则可能不为空。
}
var z6noz = unique.Make(addrDetail{isV6: true})
// WithZone 返回一个带有指定 zone 的 IP,若 zone 为空则移除 zone。
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,且 zone 是地址标识的一部分,因此对其进行规范化有助于减少内存占用。驻留后,zone 名称的比较仅需指针比较,从而提升性能。
关于字符串驻留的说明
尽管 unique
包非常有用,但它在处理字符串驻留时存在一些不同之处。要防止字符串被从内部 Map 中移除,必须同时保留 Handle[T]
和字符串值。
字符串的特殊之处在于,它们虽然像值一样工作,但实际上是指针。因此,理论上可以只对字符串的底层存储进行驻留,而不暴露 Handle[T]
的细节。未来可能会实现所谓的透明字符串驻留,类似于 Intern
函数,但具有更灵活的语义。
目前,可以通过 unique.Make("my string").Value()
进行一定程度的字符串驻留。尽管没有保留 Handle[T]
,字符串仍会在下次垃圾回收后被清理,因此在短期内依然能达到驻留效果。
历史背景与未来展望
实际上,自 net/netip
包引入以来,它就已经对 zone 字符串进行了驻留。它使用了 go4.org/intern
包的一个内部副本,该包在 Go 支持泛型之前通过 Value
类型实现了类似于 Handle[T]
的功能。
为了实现类似行为,早期的 intern
包通过不安全的代码实现了弱指针。而弱指针现在是 unique
包的核心特性。弱指针不会阻止垃圾回收,当变量被回收时,弱指针会自动变为 nil
。
在开发 unique
包时,Go 团队添加了对弱指针的适当支持,并提出了 公开提案。此外,这项工作还促使我们重新审视终结器,并提出了一个 更高效的终结器替代方案。随着可比较值的哈希函数即将推出,Go 在内存高效缓存构建方面前景光明。
参考资料
版权声明:本文为原创文章,版权归 全栈开发技术博客 所有。
本文链接:https://www.lvtao.net/dev/go-unique.html
转载时须注明出处及本声明