整个包都只有一行有效代码,或许是一件值得思考的事情
闲逛GitHub的时候发现 Brad Fitzpatrick的iter包。仔细看了2遍。代码里确实只有一行有效代码
代码语言:go复制func N(n int) []struct{} {
return make([]struct{}, n)
}
刚开始也是一扫而过,然后看了看注释
代码语言:shell复制It does not cause any allocations.
既然有这么多star还有几乎没提issue,我首先假定了他的注释是对的。立马想到空结构体 struct{} 是不占据空间的,典型的在写代码的时候,会经常这么写来判断某些值是否在之前出现过
代码语言:go复制m := make(map[string]struct{}, 0)
以及 空结构体的切片只占用切片头的空间。
但是关于切片的印象是占据24个字节,在64位机器上
代码语言:go复制var a []int
fmt.Println(unsafe.Sizeof(a))
// 这里会打印出来24
所以是否作者写的是错的,为什么说 函数 N 不会引发分配呢?
为了解决这个疑惑,需要先弄清楚两个问题:
- 一个 Go 变量可能会被分配在哪里?
- 如何确定一个 Go 变量最终会被分配在哪里?变量的分配
图片来自 这里 图 6-1
- 初始化的全局变量或静态变量,会被分配在 Data 段。
- 未初始化的全局变量或静态变量,会被分配在 BSS 段。
- 在函数中定义的局部变量,会被分配在堆(Heap 段)或栈(Stack 段)。
- 实际上,如果考虑到 编译器优化,局部变量还可能会被 分配在寄存器,或者直接被 优化去掉。
Go 内存分配
- 堆(heap)
- 由 GC负责回收。
- 对应于进程地址空间的堆。
- 栈(stack)
- 不涉及 GC操作。
- 每个 goroutine 都有自己的栈,初始时被分配在进程地址空间的栈上,扩容时被分配在进程地址空间的堆上。
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)
逃逸分析
代码语言:go复制package main
import "github.com/bradfitz/iter"
func main() {
for range iter.N(4) {}
}
代码语言:shell复制go run -gcflags='-m -m' main.go
# command-line-arguments
./main.go:5:6: can inline main with cost 7 as: func() { for loop }
./main.go:6:18: inlining call to iter.N
./main.go:6:18: make([]struct {}, iter.n) escapes to heap:
./main.go:6:18: flow: {heap} = &{storage for make([]struct {}, iter.n)}:
./main.go:6:18: from make([]struct {}, iter.n) (non-constant size) at ./main.go:6:18
./main.go:6:18: make([]struct {}, iter.n) escapes to heap
按照前面的分析,从 “make([]struct {}, iter.n) escapes to heap” 的信息,推断:make([]struct {}, iter.n) 会被分配在堆上。
到这里,最初的疑惑似乎已经有了答案:make([]struct {}, iter.n) 一定会引发堆分配,那是 Brad Fitzpatrick 的注释写错了吗?
内存分配器追踪
除了逃逸分析,Go 还提供了一种叫内存分配器追踪(Memory Allocator Trace)的方法,用于细粒度地分析由程序引发的所有堆分配(和释放)操作:
代码语言:shell复制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.
代码语言:shell复制go tool compile -N -l -S main.go
0x0014 00020 (escape/p10/main.go:8) MOVQ AX, main.n 88(SP)
0x0019 00025 (escape/p10/main.go:8) MOVQ $0, main.~r0 24(SP)
0x0022 00034 (escape/p10/main.go:8) MOVUPS X15, main.~r0 32(SP)
0x0028 00040 (escape/p10/main.go:9) MOVQ main.n 88(SP), CX
0x002d 00045 (escape/p10/main.go:9) MOVQ main.n 88(SP), BX
0x0032 00050 (escape/p10/main.go:9) LEAQ type:struct {}(SB), AX
0x0039 00057 (escape/p10/main.go:9) PCDATA $1, $0
0x0039 00057 (escape/p10/main.go:9) CALL runtime.makeslice(SB)
可以看到,其中有一处对 runtime.makeslice(SB) 的调用,显然是由 make([]struct{}, n) 引发的。
查看 runtime.makeslice 的源码:
代码语言:go复制func makeslice(et *_type, len, cap int) slice {
...
p := mallocgc(et.size*uintptr(cap), et, true)
return slice{p, len, cap}
}
其中,mallocgc 的源码如下:
代码语言:go复制func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size == 0 {
return unsafe.Pointer(&zerobase)
}
...
if debug.allocfreetrace != 0 {
tracealloc(x, size, typ)
}
...
}
结合上述几段源码,可以看出:
- 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 源码)。
相关阅读
- The empty struct
- Go Slices: usage and internals
- Escape analysis
- Go’s Memory Allocator - Overview
- Go internals, Chapter 1: Go assembly
- Five things that make Go fast
我正在参与2023腾讯技术创作特训营第四期有奖征文,快来和我瓜分大奖!