今天的帖子来自于最近的 Go 语言的一次小测试,观察下面的测试基础片段 [1]:
func BenchmarkSortStrings(b *testing.B) { s := string{"heart", "lungs", "brain", "kidneys", "pancreas"} b.ReportAllocs for i := 0; i
sort.Strings是sort.StringSlice(s)的便捷包装器,sort.Strings在原地对输入进行排序,因此不会分配内存(或至少 43% 回答此问题的 Twitter 用户是这么认为的)。然而,至少在 Go 的最近版本中,基准测试的每次迭代都会导致一次堆分配。为什么会是这种情况?
正如所有 Go 程序员应该知道的那样,接口是以 双词结构实现的。每个接口值包含一个字段,其中保存接口内容的类型,以及指向接口内容的指针。[2]
在 Go 语言伪代码中,一个接口可能是这样的:
type interface struct { // the ordinal number for the type of the value // assigned to the interface type uintptr // (usually) a pointer to the value assigned to // the interface data uintptr}
interface.data可以容纳一个机器字(在大多数情况下为 8 个字节),但一个string却需要 24 个字节:一个字用于指向切片的底层数组;一个字用于存储切片的长度;另一个字用于存储底层数组的剩余容量。那么,Go 是如何将 24 个字节装入个 8 个字节的呢?通过编程中最古老的技巧,即间接引用。一个string,即s,需要 24 个字节;但*string—— 即指向字符串切片的指针,只需要 8 个字节。
逃逸到堆
为了让示例更加明确,以下是重新编写的基准测试,不使用 sort.Strings辅助函数:
func BenchmarkSortStrings(b *testing.B) { s := string{"heart", "lungs", "brain", "kidneys", "pancreas"} b.ReportAllocs for i := 0; i
为了让接口正常运行,编译器将赋值重写为 var si sort.Interface = &ss,即ss的地址分配给接口值。[3]我们现在有这么一种情况:出现一个持有指向ss的指针的接口值。它指向哪里?还有ss存储在哪个内存位置?
似乎 ss被移动到了堆上,这也同时导致了基准测试报告中的分配:
Total: 296.01MB 296.01MB (flat, cum) 99.66% 8 . . func BenchmarkSortStrings(b *testing.B) { 9 . . s := string{"heart", "lungs", "brain", "kidneys", "pancreas"} 10 . . b.ReportAllocs 11 . . for i := 0; i
发生这种分配是因为编译器当前无法确认 ss比si生存期更长。Go 编译器开发人员对此的普遍态度是,觉得这个问题改进的余地,不过我们另找时间再议。事实上,ss就是被分配到了堆上。因此,问题变成了:每次迭代会分配多少个字节?为什么不去询问testing包呢?
% go test -bench=. sort_test.gogoos: darwingoarch: amd64cpu: Intel(R) Core(TM) i7-5650U CPU @ 2.20GHzBenchmarkSortStrings-4 12591951 91.36 ns/op 24 B/op 1 allocs/opPASSok command-line-arguments 1.260s
可以看到,在 amd 64 平台的 Go 1.16 beta1 版本上,每次操作会分配 24 字节。[4]然而,在同一平台先前的 Go 版本中,每次操作则消耗了 32 字节。
% go1.15 test -bench=. sort_test.gogoos: darwingoarch: amd64BenchmarkSortStrings-4 11453016 96.4 ns/op 32 B/op 1 allocs/opPASSok command-line-arguments 1.225s
这引出了本文的主题,即 Go 1.16 版本中即将推出的一项便利改进。不过在讨论这个内容之前,我需要聊聊 “尺寸类别size class”。