Go的1.23版本中引入了一个新的标准库包unique[2],为Go开发者带来了高效的值interning能力。这种能力不仅适用于字符串类型值,还可应用于任何可比较(comparable)类型的值。
本文将简要探讨interning技术及其在Go中的实现方式,通过介绍unique包的功能,帮助读者更好地理解这一技术及其实际应用。
1. 从string interning技术说起
通常提到interning技术时,指的是传统的字符串驻留(string interning)技术。它是一种优化方法,旨在减少程序中重复字符串的内存占用,并提高字符串比较操作的效率。其基本原理是将相同的字符串值在内存中只存储一次,所有对该字符串的引用都指向同一内存地址,而不是为每个相同字符串创建单独的副本。下图展示了使用和不使用string interning技术的对比:
这个图直观地展示了string interning如何通过共享相同的字符串来节省内存和提高效率。我们看到:在不使用string interning的情况下,每个字符串都有自己的内存分配,即使内容相同,比如"Hello"字符串出现两次,占用了两块不同的内存空间。而在使用string interning的情况下,相同内容的字符串只存储一次,比如:两个"Hello"字符串引用指向同一个内存位置。
string interning在多种场景下非常有用,比如在解析文本格式(如XML、JSON)时,interning能高效处理标签名称经常重复的问题;在编译器或解释器的实现时,interning能够减少符号表中的重复项等。
传统的string interning通常使用哈希表或字典来存储字符串的唯一实例。每次出现新字符串时,程序首先会检查哈希表中是否已有相同的字符串,若存在则返回其引用,若不存在则将其存储在表中。
Michael Knyszek在Go官博介绍interning技术[3]时,也给出了一个传统实现的代码片段:
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
}
这种实现虽然简单,但Knyszek指出了其存在几个问题:
一旦字符串被intern,就永远不会被释放。
在多goroutine环境下使用需要额外的同步机制。
仅限于字符串类型值,不能用于其他类型的值。
Go 1.23版本引入的unique包就是string interning技术的一种Go官方实现,当然就像前面所说,unique包不仅仅支持传统的string interning,还支持任何支持比较的类型的值的interning。
不过,在介绍unique包之前,我们简单看看这些年来Go社区对interning技术的贡献。
2. Go社区interning技术的实现简史
由于其他主流语言都或多或少有了对string interning的支持,Go社区显然也需要这样的包,在Go issues列表中,我能找到的最早提出在Go中添加interning技术实现的是2013年go核心开发人员Brad Fitzpatrick提出的"proposal: runtime: optionally allow callers to intern strings[4]"。
2019年,Josh Bleecher Snyder发表了一篇博文Interning strings in Go[5],探讨了interning的Go实现方法,并给出一个简单但重度使用sync.Pool的interning实现[6],该实现支持对string和字节切片的interning。
2021年,tailscale为了实现可以高效表示ip地址的netaddr包[7],构建和开源了go4.org/intern包[8],这是一个可用于量产级别的interning实现。
注:go4.org中这个go4的名字很可能就是因为go4.org这个组织只有四个contributors:Brad Fitzpatrick、Josh Bleecher Snyder、Dave Anderson和Matt Layher。之前的一篇文章《理解unsafe-assume-no-moving-gc包》中的unsafe-assume-no-moving-gc包也是go4.org下面的。
之后,Brad Fitzpatrick将inetaf/netaddr包的实现合并到了Go标准库net/netip中,而netaddr包依赖的go4.org/intern包也被移入Go项目,变为internal/intern包,并被net/netip包所使用。
直到2023年9月,mknyszek提出"unique: new package with unique.Handle[10]"的proposal,给出unique包的API设计和参考实现。unique落地后,原先使用internal/intern包的net/netip也都改为使用unique包了,internal/intern在Go 1.23版本被移除。
接下来,我们来看看这篇文章的主角unique包。
3. Go的unique包介绍
相较于传统的interning实现以及Go社区之前的实现,Go 1.23引入的unique包提供了一个更加通用和高效的interning实现方案。下面我们就分别从API、unique包的优势以及实现原理等几个方面介绍一下这个包。
3.1 unique包的API
从用户角度看,unique包提供的核心API非常简洁:
$go doc unique.Handle
package unique // import "unique"
type Handle[T comparable] struct {
// Has unexported fields.
}
func Make[T comparable](value T "T comparable") Handle[T]
func (h Handle[T]) Value() T
Make函数就是unique包的"Intern"函数,它接受一个可比较类型的值,返回一个intern后的值,不过和前面那个传统实现方式的Intern函数不同,Make函数返回的是一个Handle[T]类型的值。针对同一个传给Make函数的值,返