我一直在研究即将发布的 Go 1.23 版本,因此我很想了解这个新 unique 软件包以及它旨在解决的问题。下面是对该调查的总结,我希望它对其他有兴趣了解更多信息的人有用。
1
Interning 和 Go
Interning,最初由 Lisp 引入的一个术语,是指在内存中只存储一个值并共享对它的唯一引用的过程,而不是分配多个副本并浪费内存。例如,Go 编译器已经在编译时执行了字符串常量的驻留,因此它不会分配多个相同的字符串,而是分配该字符串的单个实例并共享对它的引用,如下面的代码片段所示。
package mainimport ("fmt""reflect""unsafe"
)const greet1 = "hello world"
const greet2 = "hello world"func main() {a := greet1p := (*reflect.StringHeader)(unsafe.Pointer(&a))fmt.Println("a address:", *&p.Data)b := greet2p2 := (*reflect.StringHeader)(unsafe.Pointer(&b))fmt.Println("b address:", *&p2.Data)
}
$ go run main.goa address: 4310983661
b address: 4310983661
在 Go 1.23 之前,Interning 运行时值仅可通过第三方包获得。但是,从 Go 1.23 开始,Interning 现已通过新 unique 包包含在标准库中。
2
uniqueGo 1.23 中的包
现在可以使用 unique 包通过其 Handle 类型执行比较,该类型充当任何提供的(可比较)值的全局唯一标识,这意味着如果用于创建句柄的两个值的比较也相等,则两个句柄的比较结果完全相等。
type Handle[T comparable] struct {}
func (h Handle[T]) Value() T {}
func Make[T comparable](value T) Handle[T] {}
在内部,该 Handle 类型由并发安全的 Map 支持,它充当读取缓存,在缓存中未检测到时存储唯一值并返回一个 Handle[T] 旨在作为值/字段而不是底层值传递的,为您节省额外的分配,并且导致更便宜的比较,因为您只会进行引用比较而不是值比较。
但是你不能用 Map 来实现相同的行为吗?
当然,使用自定义 Map 可以实现相同的驻留行为,以减少重复分配,但无法有效地处理垃圾收集。该 unique 包有一个“弱引用”的概念,这意味着垃圾收集器可以通过访问运行时内部结构在单个周期内进行清理,这是自定义滚动解决方案无法执行的。
3
如何使用该 unique 包
让我们看一个简单的例子,该例子受到 net/netip 包的启发,实际上使用该 unique 包来有效处理 IPv6 区域名称。
package mainimport ("unique"
)type addrDetail struct {isV6 boolzoneV6 string
}func main() {h1 := unique.Make(addrDetail{isV6: true, zoneV6: "2001:0db8:0001:0000:0000:0ab9:C0A8:0102"})// this addrDetail won't be allocated as it already exists in the underlying maph2 := unique.Make(addrDetail{isV6: true, zoneV6: "2001:0db8:0001:0000:0000:0ab9:C0A8:0102"})if h1 == h2 {println("addresses are equal")}// Value() returns a copy of the underlying value (ie, different memory address)println(h1.Value().zoneV6)
}
4
处理比较性能
前面我提到,除了通过重复值删除减少不必要的分配之外,该 unique 包还可以降低对象比较的成本,这在比较大字符串、具有字符串字段的结构或数组时尤其明显,其中比较可以简化为简单的指针比较。让我们来看看。
下面我们有一个基准,它重复以逗号分隔的 IPv6 区域字符串,然后对两个相同的副本执行字符串比较,一个基准没有将字符串包装在类型中 Handle,另一个基准有。
package mainimport ("strings""testing""unique"
)func BenchmarkStringCompareSmall(b *testing.B) { benchStringComparison(b, 10) }
func BenchmarkStringCompareMedium(b *testing.B) { benchStringComparison(b, 100) }
func BenchmarkStringCompareLarge(b *testing.B) { benchStringComparison(b, 1000000) }func BenchmarkCanonicalisingSmall(b *testing.B) { benchCanonicalising(b, 10) }
func BenchmarkCanonicalisingMedium(b *testing.B) { benchCanonicalising(b, 100) }
func BenchmarkCanonicalisingLarge(b *testing.B) { benchCanonicalising(b, 1000000) }func benchStringComparison(b *testing.B, count int) {s1 := strings.Repeat("2001:0db8:0001:0000:0000:0ab9:C0A8:0102,", count)s2 := strings.Repeat("2001:0db8:0001:0000:0000:0ab9:C0A8:0102,", count)b.ResetTimer()for n := 0; n < b.N; n++ {if s1 != s2 {b.Fatal()}}b.ReportAllocs()
}func benchCanonicalising(b *testing.B, count int) {s1 := unique.Make(strings.Repeat("2001:0db8:0001:0000:0000:0ab9:C0A8:0102,", count))s2 := unique.Make(strings.Repeat("2001:0db8:0001:0000:0000:0ab9:C0A8:0102,", count))b.ResetTimer()for n := 0; n < b.N; n++ {if s1 != s2 {b.Fatal()}}b.ReportAllocs()
}
5
基准测试结果
让我们运行基准测试并检查结果:
$ go test -run='^$' -bench=.goos: darwin
goarch: arm64
pkg: go-experiment
cpu: Apple M1
BenchmarkStringCompareSmall-8 116581837 9.392 ns/op 0 B/op 0 allocs/op
BenchmarkStringCompareMedium-8 14944300 80.15 ns/op 0 B/op 0 allocs/op
BenchmarkStringCompareLarge-8 903 1296028 ns/op 0 B/op 0 allocs/opBenchmarkCanonicalisingSmall-8 1000000000 0.3132 ns/op 0 B/op 0 allocs/op
BenchmarkCanonicalisingMedium-8 1000000000 0.3140 ns/op 0 B/op 0 allocs/op
BenchmarkCanonicalisingLarge-8 1000000000 0.3128 ns/op 0 B/op 0 allocs/op
PASS
ok go-experiment 5.596s
运行基准测试我们可以看到,无论字符串的大小如何,无论是对 10 个副本还是 1,000,000 个副本的字符串进行比较,每个操作的纳秒数 (ns/op) 持续时间始终很低,而非之前版本相对于字符串的大小而增长。
随手关注或者”在看“,诚挚感谢!