由一行代码引发的变量分配思考
整个包都只有一行有效代码,或许是一件值得思考的事情
闲逛GitHub的时候发现 Brad Fitzpatrick的iter包。仔细看了2遍。代码里确实只有一行有效代码
1 | func N(n int) []struct{} { |
刚开始也是一扫而过,然后看了看注释
1 | It does not cause any allocations. |
既然有这么多star还有几乎没提issue,我首先假定了他的注释是对的。立马想到空结构体 struct{} 是不占据空间的,典型的在写代码的时候,会经常这么写来判断某些值是否在之前出现过
1 | m := make(map[string]struct{}, 0) |
以及 空结构体的切片只占用切片头的空间。
但是关于切片的印象是占据24个字节,在64位机器上
1 | var a []int |
所以是否作者写的是错的,为什么说 函数 N 不会引发分配呢?
为了解决这个疑惑,需要先弄清楚两个问题:
变量的分配
图片来自 这里 图 6-1
- 初始化的全局变量或静态变量,会被分配在 Data 段。
- 未初始化的全局变量或静态变量,会被分配在 BSS 段。
- 在函数中定义的局部变量,会被分配在堆(Heap 段)或栈(Stack 段)。
Go 内存分配
- 堆(heap)
- 由 GC 负责回收。
- 对应于进程地址空间的堆。
- 栈(stack)
Go 变量主要分为两种:
- 全局变量
- 会被 Go 编译器标记为一些特殊的 符号类型,分配在堆上还是栈上目前尚不清楚,不过不是本文讨论的重点。
- 局部变量
所以综上,对于在函数中定义的 Go 局部变量:要么被分配在堆上,要么被分配在栈上。
确定 Go 变量最终的分配位置
按照官方 FAQ How do I know whether a variable is allocated on the heap or the stack? 的解释:
- Go 编译器会尽可能将变量分配在栈上
- 以下两种情况,Go 编译器会将变量分配在堆上
- 如果一个变量被取地址(has its address taken),并且被逃逸分析(escape analysis)识别为 “逃逸到堆”(escapes to heap)
- 如果一个变量很大(very large)
逃逸分析
1 | package main |
1 | go run -gcflags='-m -m' main.go |
按照前面的分析,从 “make([]struct {}, iter.n) escapes to heap” 的信息,推断:make([]struct {}, iter.n) 会被分配在堆上。
到这里,最初的疑惑似乎已经有了答案:make([]struct {}, iter.n) 一定会引发堆分配,那是 Brad Fitzpatrick 的注释写错了吗?
内存分配器追踪
除了逃逸分析,Go 还提供了一种叫内存分配器追踪(Memory Allocator Trace)的方法,用于细粒度地分析由程序引发的所有堆分配(和释放)操作:
1 | GODEBUG=allocfreetrace=1 go run main.go 2>&1 | grep -C 10 |
因为进行内存分配器追踪时,很多由 runtime 引发的分配信息也会被打印出来,所以用 grep 进行过滤,只显示由用户代码(user code)引发的分配信息。然而这里的输出结果为空,表明 make([]struct {}, iter.n) 没有引发任何堆分配。
内存分配器追踪的结论与逃逸分析的结论截然相反!那到底哪个结论是对的呢?
汇编分析
黔驴技穷之际,Go’s Memory Allocator - Overview 这篇文章给了提示:
So, we know that i is going to be allocated on the heap. But how does the runtime set that up? With the compiler’s help! We can get an idea from reading the generated assembly.
1 | go tool compile -N -l -S main.go |
可以看到,其中有一处对 runtime.makeslice(SB) 的调用,显然是由 make([]struct{}, n) 引发的。
查看 runtime.makeslice 的源码:
1 | func makeslice(et *_type, len, cap int) slice { |
其中,mallocgc 的源码如下:
1 | func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer { |
结合上述几段源码,可以看出:
- makeslice 函数中:slice 结构体是 Go 切片 —— array 是指向数组片段的指针,len 是数组片段的长度,cap 是数组片段的最大长度。
- makeslice 函数中:array 的值来自 p,而 p 则是一个指针,它指向由 mallocgc 分配得到的底层数组。
- mallocgc 函数中:因为空结构体的 size 为 0,所以 mallocgc 并没有实际进行堆分配;由于没有执行到 tracealloc 的地方,所以进行内存分配器追踪时,不会采集到相关的分配信息。
- makeslice 函数中:切片 slice 本身是以结构体的形式返回的,所以只会被分配在栈上。
总结
经过一系列的探索和分析,至此,可以得出以下结论:
- make([]struct{}, n) 只会被分配在栈上,而不会被分配在堆上。
- Brad Fitzpatrick 的注释是对的,并且他的意思是 “不会引发堆分配”。
- 逃逸分析识别出 escapes to heap,并不一定就是堆分配,也可能是栈分配。
- 进行内存分配器追踪时,如果采集不到堆分配信息,那一定只有栈分配。
最后,来解答文章标题提出的疑问 —— 如何确定一个 Go 变量会被分配在哪里?对此:
- 先对代码作逃逸分析。
- 如果该变量被识别为 escapes to heap,那么它十有八九是被分配在堆上。
- 如果该变量被识别为 does not escape,或者没有与之相关的分析结果,那么它一定是被分配在栈上。
- 如果对 escapes to heap 心存疑惑,就对代码作内存分配器追踪。
- 如果有采集到与该变量相关的分配信息,那么它一定是被分配在堆上。
- 否则,该变量一定是被分配在栈上。
- 此外,如果想知道 Go 编译器是如何将变量分配在堆上或者栈上的,可以去分析 Go 汇编(以及 runtime 源码)。
相关阅读
由一行代码引发的变量分配思考