由一行代码引发的变量分配思考

2023-12-25 08:01:03 浏览数 (1)

整个包都只有一行有效代码,或许是一件值得思考的事情

闲逛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 不会引发分配呢?

为了解决这个疑惑,需要先弄清楚两个问题:

  1. 一个 Go 变量可能会被分配在哪里?
  2. 如何确定一个 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 变量会被分配在哪里?对此:

  1. 先对代码作逃逸分析
    • 如果该变量被识别为 escapes to heap,那么它十有八九是被分配在堆上。
    • 如果该变量被识别为 does not escape,或者没有与之相关的分析结果,那么它一定是被分配在栈上。
  2. 如果对 escapes to heap 心存疑惑,就对代码作内存分配器追踪
    • 如果有采集到与该变量相关的分配信息,那么它一定是被分配在堆上。
    • 否则,该变量一定是被分配在栈上。
  3. 此外,如果想知道 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腾讯技术创作特训营第四期有奖征文,快来和我瓜分大奖!

0 人点赞