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

举报
Rolle 发表于 2023/11/16 22:27:46 2023/11/16
【摘要】 整个包都只有一行有效代码,或许是一件值得思考的事情闲逛GitHub的时候发现 Brad Fitzpatrick的iter包。仔细看了2遍。代码里确实只有一行有效代码func N(n int) []struct{} { return make([]struct{}, n)}刚开始也是一扫而过,然后看了看注释It does not cause any allocations.既然有这么多star...

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

闲逛GitHub的时候发现 Brad Fitzpatrick的iter包。仔细看了2遍。代码里确实只有一行有效代码

func N(n int) []struct{} {
	return make([]struct{}, n)
}

刚开始也是一扫而过,然后看了看注释

It does not cause any allocations.

既然有这么多star还有几乎没提issue,我首先假定了他的注释是对的。立马想到空结构体 struct{} 是不占据空间的,典型的在写代码的时候,会经常这么写来判断某些值是否在之前出现过

m := make(map[string]struct{}, 0)

以及 空结构体的切片只占用切片头的空间。

但是关于切片的印象是占据24个字节,在64位机器上

var a []int
fmt.Println(unsafe.Sizeof(a))
// 这里会打印出来24

所以是否作者写的是错的,为什么说 函数 N 不会引发分配呢?

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

  1. 一个 Go 变量可能会被分配在哪里?
  2. 如何确定一个 Go 变量最终会被分配在哪里?
    变量的分配

image.png

图片来自 这里 图 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)

逃逸分析

package main

import "github.com/bradfitz/iter"

func main() {
        for range iter.N(4) {}
}
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)的方法,用于细粒度地分析由程序引发的所有堆分配(和释放)操作:

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.

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 的源码:

func makeslice(et *_type, len, cap int) slice {
	...
	p := mallocgc(et.size*uintptr(cap), et, true)
	return slice{p, len, cap}
}

其中,mallocgc 的源码如下:

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 源码)

相关阅读

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。