一文理解Go中的内存分配
文章代码 allocations代码[2]
简介
由于Go运行时的高效内置内存管理,我们通常能够在程序中优先考虑正确性和可维护性,而不需要过多考虑分配的细节。不过,我们有时会发现代码中的性能瓶颈,并想深入了解一下。
任何使用-benchmem
标志运行过benchmark的人都会看到如下输出中的allocs/op
统计。在这篇文章中,我们将看看什么算作分配,以及我们可以做什么来影响这个数字。
BenchmarkFunc-8 67836464 16.0 ns/op 8 B/op 1 allocs/op
我们熟悉和喜爱的栈和堆
为了讨论Go中的allocs/op
统计,我们将对Go程序中的两个内存区域感兴趣:栈和堆。
在许多流行的编程环境中,栈通常指的是线程的调用栈。调用堆栈是一个LIFO后进先出的堆栈数据结构,用于存储参数、局部变量以及线程执行函数时跟踪的其他数据。每个函数调用都会向栈添加(push)一个新的栈帧(frame),每个返回的函数都会从堆栈中删除(pop)。
当最近的栈帧被弹出时,我们必须能够安全地释放它的内存。因此,我们不能在栈上存储任何后来需要在其他地方被引用的东西。
由于线程是由操作系统管理的,线程堆栈的可用内存量通常是固定的,例如,在许多Linux环境中默认为8MB。这意味着我们还需要注意有多少数据最终会出现在堆栈上,特别是在深度嵌套的递归函数的情况下。如果上图中的堆栈指针通过了栈保护,程序将因栈溢出错误而崩溃。
堆是一个更复杂的内存区域,与同名的数据结构没有关系。我们可以根据需要使用堆来存储我们程序中需要的数据。在这里分配的内存不能简单地在函数返回时被释放,需要仔细管理以避免泄漏和碎片化。堆通常会比任何线程堆栈大很多倍,任何优化工作的大部分都将用于调查堆的使用。
Go栈和堆
由操作系统管理的线程被Go运行时完全抽象化了,我们转而使用一种新的抽象:Goroutines
。goroutines
在概念上与线程非常相似,但它们存在于用户空间。这意味着运行时,而不是操作系统,设定了堆栈的行为规则。
抽象出来的线程
goroutine堆栈不是由操作系统设定的硬性限制,而是以少量内存(目前为2KB)开始。在每个函数调用被执行之前,在函数序言中执行一个检查,以验证堆栈溢出不会发生。在下图中,convert()
函数可以在当前堆栈大小的限制范围内执行(没有SP溢出堆栈guard0
)。
如果不是这样,运行时就会在执行convert()
之前将当前堆栈复制到一个新的较大的连续内存空间。这意味着Go中的堆栈是动态大小的,只要有足够的内存可供使用,堆栈通常可以不断地增长。
Go堆在概念上又与上述的线程模型相似。所有的goroutine
共享一个公共的堆,任何不能存储在堆上的东西最终都会被放在那里。当一个被测试的函数发生堆分配时,我们会看到 allocs/ops
的统计数字在增加。垃圾回收器的工作是随后释放不再被引用的堆变量。
关于Go中如何处理内存管理的详细解释,请看A visual guide to Go Memory Allocator from scratch[3]。
我们如何知道变量何时分配到堆中呢?
这个问题在官方FAQ[4]中已经解答了。
Go编译器会将属于某个函数的局部变量分配到该函数的栈帧中。然而,如果编译器在函数返回后无法证明该变量未被引用,那么编译器必须将该变量分配到GC的堆中,以避免悬空指针(这里指指针指向的对象已经释放)错误。另外,如果一个局部变量非常大,把它存储在堆上可能比存储在栈上更有意义。 如果一个变量的地址被占用,那么这个变量就是在堆上分配的候选变量。然而,一个基本的逃逸分析会识别到某些情况,即这样的变量在函数返回之后将不再存在,并且可以驻留在栈上。
由于编译器的实现随着时间的推移而变化,因此没有办法仅仅通过阅读Go代码就知道哪些变量会被分配到堆中。但是,可以在编译器的输出中查看上述逃逸分析的结果。这可以通过传递给go build
的gcflags
参数来实现。完整的参数选项列表可以通过go tool compile -help
进行查看。
对于逃逸分析结果,可以使用-m
选项(打印优化决定)。让我们用一个简单的程序来测试一下,这个程序为函数main1
和stackIt
创建了两个栈帧。
func main1() {
_ = stackIt()
}
//go:noinline
func stackIt() int {
y := 2
return y * 2
}
因为如果编译器删除了我们的函数调用,我们就不能讨论堆栈行为,所以noinline
pragma[5]被用来防止在编译代码时进行内联。让我们来看一下编译器优化有什么说法。-l
选项是用来省略内联的。
$ go build -gcflags '-m -l'
# github.com/Jimeux/go-samples/allocations
在这里,我们看到没有做出关于逃逸分析的决定。换句话说,变量y
仍然在栈上,并没有触发任何堆分配。我们可以用一个benchmark
来验证这一点。
$ go test -bench . -benchmem
BenchmarkStackIt-8 680439016 1.52 ns/op 0 B/op 0 allocs/op
正如预期的那样,allocs/op
显示为0。从这个结果中我们可以得出一个重要的观察结果,即复制变量可以让我们把它们留在栈中,避免分配到堆中。让我们通过修改程序来验证这一点,以避免使用指针进行复制。
func main2() {
_ = stackIt2()
}
//go:noinline
func stackIt2() *int {
y := 2
res := y * 2
return &res
}
让我们看看编译器的输出。
go build -gcflags '-m -l'
# github.com/Jimeux/go-samples/allocations
./main.go:10:2: moved to heap: res
编译器告诉我们它把指针res
移到了堆中,触发了堆分配,在下面的benchmark中也得到了验证
$ go test -bench . -benchmem
BenchmarkStackIt2-8 70922517 16.0 ns/op 8 B/op 1 allocs/op
那么这是否意味着指针可以保证创建分配?让我们再次修改程序,这次是将一个指针传到栈中。
func main3() {
y := 2
_ = stackIt3(&y) // pass y down the stack as a pointer
}
//go:noinline
func stackIt3(y *int) int {
res := *y * 2
return res
}
然而运行benchmark
显示没有任何东西被分配到堆中。
$ go test -bench . -benchmem
BenchmarkStackIt3-8 705347884 1.62 ns/op 0 B/op 0 allocs/op
编译器的输出明确地告诉我们这一点。
$ go build -gcflags '-m -l'
# github.com/Jimeux/go-samples/allocations
./main.go:10:14: y does not escape
为什么会出现这种看似不一致的情况呢? stackIt2
将y
的地址从栈上传到main
,在那里,y
将在stackIt2
的栈帧已经被释放之后被引用。因此,编译器能够判断y
必须被移到堆中以保持存活。如果它不这样做,当试图引用 y
时,我们会在 main
中得到一个 nil
指针。
另一方面,stackIt3
将 y
传递到栈中,并且 y
没有在 main3
之外被引用。因此,编译器能够判断y
可以单独存在于栈中,而不需要分配到堆中。在任何情况下,我们都不可能通过引用y
来产生一个nil
指针。
我们可以从中推断出一个一般规则,即在栈上共享指针结果会导致分配,而在栈上共享指针则不会。 然而,这并不能保证,所以你仍然需要用gcflags
或benchmark
来确定。我们可以肯定的是,任何减少allocs/op
的尝试都会涉及到寻找任性的指针。
为什么我们要关心堆的分配?
我们已经学习了一些关于allocs/op
中alloc
的含义,以及如何验证是否触发了对堆的分配,但为什么我们首先要关心这个状态是否为非零?我们已经做过的benchmark可以开始回答这个问题。
BenchmarkStackIt-8 680439016 1.52 ns/op 0 B/op 0 allocs/op
BenchmarkStackIt2-8 70922517 16.0 ns/op 8 B/op 1 allocs/op
BenchmarkStackIt3-8 705347884 1.62 ns/op 0 B/op 0 allocs/op
尽管所涉及的变量的内存需求几乎相等,但BenchmarkStackIt2
的相对CPU开销是很明显的。我们可以通过生成stackIt
和stackIt2
实现的CPU配置文件的火焰图来获得更多的了解。
stackIt
有一个不起眼的配置文件,它可预测地沿着调用栈运行到stackIt
函数本身。另一方面,stackIt2
正在大量使用大量的运行时函数,这些函数会吃掉许多额外的CPU周期。这表明了分配到堆的复杂性,并初步了解了每个操作额外的10多纳秒的消耗在哪里。
在现实世界中呢?
如果没有线上生产条件,性能的许多方面并不明显。你的单个函数可能在微观测试中有效运行,但当它为成千上万的并发用户提供服务时,它对你的应用程序有什么影响?
在这篇文章中,我们不打算重新创建一个完整的应用程序,但我们将使用trace工具[6]看一下一些更详细的性能诊断方法。让我们首先定义一个有九个字段的(有点)的大结构体BigStruct
。
type BigStruct struct {
A, B, C int
D, E, F string
G, H, I bool
}
现在我们定义两个函数。CreateCopy
在栈帧之间复制BigStruct
实例;CreatePointer
在堆上共享BigStruct
指针,避免复制,但会导致堆分配。
//go:noinline
func CreateCopy() BigStruct {
return BigStruct{
A: 123, B: 456, C: 789,
D: "ABC", E: "DEF", F: "HIJ",
G: true, H: true, I: true,
}
}
//go:noinline
func CreatePointer() *BigStruct {
return &BigStruct{
A: 123, B: 456, C: 789,
D: "ABC", E: "DEF", F: "HIJ",
G: true, H: true, I: true,
}
}
我们可以用到目前为止使用的技术来验证上面的解释。
$ go build -gcflags '-m -l'
./main.go:67:9: &BigStruct literal escapes to heap
$ go test -bench . -benchmem
BenchmarkCopyIt-8 211907048 5.20 ns/op 0 B/op 0 allocs/op
BenchmarkPointerIt-8 20393278 52.6 ns/op 80 B/op 1 allocs/op
下面是我们将用于trace
工具的测试。它们分别用各自的Create
函数创建了20,000,000个BigStruct
的实例。
const creations = 20_000_000
func TestCopyIt(t *testing.T) {
for i := 0; i < creations; i++ {
_ = CreateCopy()
}
}
func TestPointerIt(t *testing.T) {
for i := 0; i < creations; i++ {
_ = CreatePointer()
}
}
接下来我们将CreateCopy
的跟踪输出保存到copy_trace.out
文件中,并在浏览器中用跟踪工具打开它。
$ go test -run TestCopyIt -trace=copy_trace.out
PASS
ok github.com/Jimeux/go-samples/allocations 0.281s$ go tool trace copy_trace.out
Parsing trace...
Splitting trace...
Opening browser. Trace viewer is listening on http://127.0.0.1:57530
从菜单中选择View trace
向我们展示了下面的内容,它几乎和我们的 stackIt
函数的火焰图一样很不起眼。八个潜在的逻辑核心(Procs)中只有两个被利用了,而goroutine
G19 差不多花了整个时间来运行我们的测试循环--这就是我们想要的。
让我们生成CreatePointer
代码的跟踪数据。
$ go test -run TestPointerIt -trace=pointer_trace.out
PASS
ok github.com/Jimeux/go-samples/allocations 2.224s
go tool trace pointer_trace.out
Parsing trace...
Splitting trace...
Opening browser. Trace viewer is listening on http://127.0.0.1:57784
你可能已经注意到测试花了2.224s,而CreateCopy
只花了0.281s,而且这次选择View trace
显示的东西更加丰富多彩和繁忙。所有的逻辑核心都被利用了,而且似乎比上次有更多的堆动作、线程和goroutine。
如果我们放大到一毫秒左右的跟踪,我们会看到许多goroutines
在进行与GC[7]有关的操作。前面引用的FAQ中使用了垃圾回收堆这个短语,因为垃圾回收器的工作是清理堆上不再被引用的东西。
尽管Go的垃圾回收器越来越高效,但这个过程并不是零开销的。我们可以直观地验证,在上面的trace
输出中,测试代码有时会完全停止。而CreateCopy
则不是这样,因为我们所有的BigStruct
实例都留在栈中,GC几乎没有什么事情可做。
比较两组trace
数据中的goroutine
分析,可以对此有更深入的了解。CreatePointer
(底部)花费了超过15%的执行时间来清扫或暂停(GC)和调度goroutine
。
查看一下trace
数据中其他地方的一些统计信息,进一步说明了堆分配的代价,产生的goroutine
的数量有明显的差异,CreatePointer
测试有近400个STW(Stop The World)事件。
+------------+------+---------+
| | Copy | Pointer |
+------------+------+---------+
| Goroutines | 41 | 406965 |
| Heap | 10 | 197549 |
| Threads | 15 | 12943 |
| bgsweep | 0 | 193094 |
| STW | 0 | 397 |
+------------+------+---------+
请记住,尽管本节的标题是 CreateCopy
测试,但在一个典型的程序中,其条件是非常不切实际的。看到GC使用稳定的CPU是很正常的,而且指针是任何真实程序的一个特征。然而,这和前面的火焰图一起给了我们一些启示,为什么我们可能要跟踪allocs/op
统计数据,并尽可能避免不必要的堆分配。
总结
希望这篇文章能让我们深入了解Go程序中栈和堆的区别,allocs/op
的含义,以及一些我们可以调查内存使用情况的方法。
我们的代码的正确性和可维护性通常要优先于减少指针使用和规避GC活动的棘手技术。现在每个人都知道关于过早优化的说法,用Go编码也不例外。
然而,如果我们确实有严格的性能要求,或者在程序中发现了瓶颈,那么这里介绍的概念和工具将有望成为进行必要优化的一个有用的起点。
参考资料
Understanding Allocations in Go: https://medium.com/eureka-engineering/understanding-allocations-in-go-stack-heap-memory-9a2631b5035d
[2]allocations代码: https://github.com/Jimeux/go-samples/tree/master/allocations
[3]A visual guide to Go Memory Allocator from scratch: https://medium.com/@ankur_anand/a-visual-guide-to-golang-memory-allocator-from-ground-up-e132258453ed
[4]官方FAQ: https://go.dev/doc/faq#stack_or_heap
[5]pragma: https://dave.cheney.net/2018/01/08/gos-hidden-pragmas
[6]trace工具: https://pkg.go.dev/cmd/trace
[7]GC: https://www.ardanlabs.com/blog/2018/12/garbage-collection-in-go-part1-semantics.html
我是 polarisxu,北大硕士毕业,曾在 360 等知名互联网公司工作,10多年技术研发与架构经验!2012 年接触 Go 语言并创建了 Go 语言中文网!著有《Go语言编程之旅》、开源图书《Go语言标准库》等。
坚持输出技术(包括 Go、Rust 等技术)、职场心得和创业感悟!欢迎关注「polarisxu」一起成长!也欢迎加我微信好友交流:gopherstudio