Golang - 新的unique包

文摘   2024-09-02 14:48   美国  

Go 1.23标准库现在包含了 新的[2] 。这个包的目的是实现可比较值的规范化。换句话说,这个包让你可以对值进行去重,使它们指向单一的、规范的、唯一的副本,同时在底层高效地管理这些规范副本。你可能已经熟悉这个概念,称为 "内部化"[3] 。让我们深入了解它是如何工作的,以及为什么它很有用。

内部化的简单实现 ¶[4]

从高层次来看,内部化非常简单。看看下面的代码示例,它仅使用普通的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
}

这在你构建大量可能重复的字符串时很有用,比如在解析文本格式时。

这个实现非常简单,在某些情况下效果很好,但它有一些问题:

  • 它从不从池中移除字符串。
  • 它不能被多个goroutine安全地并发使用。
  • 它只适用于字符串,尽管这个想法相当通用。

这个实现中还有一个被忽视的机会,而且很微妙。在底层, 字符串是由指针和长度组成的不可变结构[5] 。在比较两个字符串时,如果指针不相等,那么我们必须比较它们的内容来确定相等性。但是如果我们知道两个字符串是规范化的,那么仅检查它们的指针就足够了。

进入unique包 ¶[6]

新的unique包引入了一个类似于Intern的函数,称为 Make[7]

它的工作方式与Intern大致相同。在内部也有一个全局map( 一个快速的通用并发map[8] ),Make在该map中查找提供的值。但它在两个重要方面与Intern不同。首先,它接受任何可比较类型的值。其次,它返回一个包装值,即 Handle[T][9] ,可以从中检索规范值。

这个Handle[T]是设计的关键。Handle[T]具有这样的属性:当且仅当用于创建它们的值相等时,两个Handle[T]值才相等。更重要的是,比较两个Handle[T]值的成本很低:它归结为指针比较。与比较两个长字符串相比,这要便宜一个数量级!

到目前为止,这没有什么是你在普通Go代码中做不到的。

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

如果你以前使用过Lisp,这一切可能听起来很熟悉。Lisp 符号[10] 是内部化的字符串,但不是字符串本身,所有符号的字符串值保证在同一个池中。符号和字符串之间的这种关系类似于Handle[string]string之间的关系。

一个真实世界的例子 ¶[11]

那么,如何使用unique.Make呢?不用再看了,标准库中的net/netip包就是一个例子,它对addrDetail类型的值进行内部化,这是 netip.Addr[12] 结构的一部分。

下面是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值更加高效,因为比较区域名称变成了简单的指针比较。

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

但字符串很特殊,虽然它们表现得像值,但实际上它们在底层包含指针,正如我们之前提到的。这意味着我们可能可以仅规范化字符串的底层存储,将 Handle[T] 的细节隐藏在字符串本身内部。因此,未来仍有空间为我称之为透明字符串内化的功能,其中字符串可以在没有 Handle[T] 类型的情况下被内化,类似于 Intern 函数但语义更接近 Make

同时,unique.Make("my string").Value() 是一种可能的变通方法。即使未能保留句柄会允许字符串从 unique 的内部映射中被删除,映射条目也不会立即被删除。实际上,条目至少要等到下一次垃圾回收完成后才会被删除,所以这种变通方法仍然允许在回收期间进行一定程度的重复数据删除。

一些历史,展望未来 ¶[13]

事实上,net/netip 包自首次引入以来就一直在内化区域字符串。它使用的内化包是 go4.org/intern[14] 包的内部副本。与 unique 包一样,它有一个 Value 类型(看起来很像泛型之前的 Handle[T]),具有一个显著特性,即一旦它们的句柄不再被引用,内部映射中的条目就会被移除。

但要实现这种行为,它必须做一些不安全的事情。特别是,它对垃圾收集器的行为做了一些假设,以在运行时之外实现 弱指针[15] 。弱指针是一种不会阻止垃圾收集器回收变量的指针;当这种情况发生时,指针会自动变为 nil。事实上,弱指针也是 unique 包的核心抽象。

没错:在实现 unique 包时,我们为垃圾收集器添加了适当的弱指针支持。在经历了伴随弱指针的令人遗憾的设计决策的雷区后(比如,弱指针应该跟踪 对象复活[16] 吗?不!),我们对最终结果的简单和直接感到惊讶。惊讶到弱指针现在成为了一个 公开提案[17]

这项工作还让我们重新审视了终结器,结果提出了另一个更易于使用和更高效的 终结器替代方案[18] 。再加上即将推出的 可比较值的哈希函数[19] ,Go 中 构建内存高效缓存[20] 的未来一片光明!

参考链接

1. Go博客: https://go.dev/blog/
2. 新的: https://pkg.go.dev/unique
3. "内部化": https://en.wikipedia.org/wiki/Interning_(computer_science)
4. ¶: https://go.dev/blog/unique#a-simple-implementation-of-interning
5. 字符串是由指针和长度组成的不可变结构: https://go.dev/blog/slices
6. ¶: https://go.dev/blog/unique#enter-the-unique-package
7. Make: https://pkg.go.dev/unique#Make
8. 一个快速的通用并发map: https://pkg.go.dev/internal/concurrent@go1.23.0
9. Handle[T]: https://pkg.go.dev/unique#Handle
10. 符号: https://en.wikipedia.org/wiki/Symbol_(programming)
11. ¶: https://go.dev/blog/unique#a-real-world-example
12. netip.Addr: https://pkg.go.dev/net/netip#Addr
13. ¶: https://go.dev/blog/unique#some-history-and-looking-toward-the-future
14. go4.org/intern: https://pkg.go.dev/go4.org/intern
15. 弱指针: https://en.wikipedia.org/wiki/Weak_reference
16. 对象复活: https://en.wikipedia.org/wiki/Object_resurrection
17. 公开提案: https://go.dev/issue/67552
18. 终结器替代方案: https://go.dev/issue/67535
19. 可比较值的哈希函数: https://go.dev/issue/54670
20. 构建内存高效缓存: https://go.dev/issue/67552#issuecomment-2200755798

幻想发生器
图解技术本质
 最新文章