图文带你了解 Go 中的分配

介绍

得益于了 Go 运行时高效的内置内存管理,我们通常能够在程序中优先考虑正确性和可维护性,而不需要过多考虑如何进行分配的细节。不过,有时我们可能会发现代码中的性能瓶颈,并希望进行更深入的研究。

任何使用 -benchmem 标志运行基准测试的人都会在输出中看到 allocs/op 的统计。在这篇文章中,我们将看看什么算作一个 alloc,以及我们可以做什么来影响这个数字。

1
BenchmarkFunc-8  67836464  16.0 ns/op  8 B/op  1 allocs/op

我们熟悉和喜爱的栈和堆

要讨论 Go 中的 allocs/op 统计,我们将对 Go 程序中的两个内存区域感兴趣:栈和堆。

在许多流行的编程环境中,栈通常指的是线程的调用栈。调用栈是一个先进先出(LIFO)栈数据结构,它存储了线程执行函数时跟踪的参数、局部变量和其他数据。每一次函数调用都向栈增加(推)一个新的帧,每一次返回函数都会从栈中删除(弹出)。

我们必须能够在最近的栈帧被弹出时安全地释放它的内存。因此,我们不能在栈上存储任何以后需要在其他地方引用的东西。

调用 println 后的调用栈视图

由于线程是由操作系统管理的,所以线程栈的可用内存量通常是固定的,例如在许多 Linux 环境中默认为 8MB。这意味着我们还需要注意栈上最终有多少数据,特别是在嵌套较深的递归函数的情况下。如果上图中的栈指针通过了栈保护,程序就会因栈溢出错误而崩溃。

堆是内存中更复杂的区域,与同名的数据结构没有关系。我们可以按需使用堆来存储程序中需要的数据。在这里分配的内存不能在函数返回时简单地释放,需要仔细管理,以避免泄漏和碎片化。堆通常会比任何线程栈大许多倍,任何优化工作的大部分时间都将花费在研究堆的使用上。

Go 栈和堆

由操作系统管理的线程被 Go 运行时完全抽象出来,我们使用的是一个新的抽象:goroutines。goroutine 在概念上与线程非常相似,但它们存在于用户空间中。这意味着是运行时而不是操作系统来设置栈的行为规则。

线程被抽离出来

goroutine 栈并不是由操作系统设置的硬性限制,而是以少量的内存(目前为 2KB)开始。在执行每个函数调用之前,在函数序言中会执行检查,以验证不会发生栈溢出。在下面的图中,convert() 函数可以在当前栈大小的限制下执行(在 SP 不超额处理 stackguard0 的情况下)。

goroutine 调用栈特写

如果不是这样,运行时将在执行 convert() 之前将当前栈复制到一个更大的连续内存空间中。这意味着 Go 中的栈是动态大小的,只要有足够的内存可用,通常就可以保持增长。

Go 堆在概念上同样类似于上面描述的线程模型。所有的 goroutines 共享一个公共堆,任何不能存储在栈上的东西都将在那里结束。当对函数进行基准测试时发生堆分配时,我们将看到allocs/ops 属性增加 1。垃圾回收器的工作是稍后释放不再引用的堆变量。

关于Go中如何处理内存管理的详细解释,请参阅 从头开始的 Go 内存分配器的可视化指南

我们如何知道一个变量何时被分配给堆?

这个问题答案在官方 FAQ 中。

Go 编译器将为函数的栈帧中分配该函数的局部变量。但如果编译器不能证明该变量在函数返回后没有被引用,那么编译器必须在垃圾回收的堆上分配变量,以避免指针悬空错误。而且,如果局部变量非常大,那么将它存储在堆上而不是栈上可能更有意义。

如果某个变量的地址已被占用,那么该变量将成为堆上分配的候选变量。然而,一个基本的转义分析可以识别出一些情况,即这样的变量不会活过函数的返回,可以驻留在栈中。

由于编译器的实现会随着时间的推移而改变,所以仅仅通过阅读 Go 代码,是无法知道哪些变量会被分配到堆中的。不过,可以在编译器的输出中查看上面提到的 escape 分析结果。这可以通过传递给 go buildgcflags 参数来实现。完整的选项列表可以通过 go tool compile -help 来查看。

对于转义分析结果,可以使用 -m 选项(打印优化决策)。让我们用一个简单的程序来测试一下,为函数 main1stackIt 创建两个栈帧。

1
2
3
4
5
6
7
8
func main1() {
_ = stackIt()
}
//go:noinline
func stackIt() int {
y := 2
return y * 2
}

因为如果编译器删除了函数调用,我们就无法讨论栈行为,所以在编译代码时使用 noinline pragma 来防止内联。让我们看一下编译器对其优化决策说些什么。-l 选项用于省略内联决策。

1
2
$ go build -gcflags '-m -l'
# github.com/Jimeux/go-samples/allocations

在这里,我们看到,没有做出任何关于逃跑分析的决定。换句话说,变量 y 保留在栈中,并没有触发任何堆分配。我们可以用基准测试来验证这一点。

1
2
$ go test -bench . -benchmem
BenchmarkStackIt-8 680439016 1.52 ns/op 0 B/op 0 allocs/op

正如预期的那样,allocs/op 统计值为 0。从这个结果中我们可以得到的一个重要观察是,复制变量可以让我们将它们保留在栈中,避免分配到堆中。让我们通过修改程序来验证这一点,以避免使用指针进行复制。

1
2
3
4
5
6
7
8
9
func main2() {
_ = stackIt2()
}
//go:noinline
func stackIt2() *int {
y := 2
res := y * 2
return &res
}

让我们看以下编译器的输出。

1
2
3
go build -gcflags '-m -l'
# github.com/Jimeux/go-samples/allocations
./main.go:10:2: moved to heap: res

编译器告诉我们,它把指针 res 移到了堆上,从而触发了堆分配,这在下面的基准中得到了验证。

1
2
$ go test -bench . -benchmem
BenchmarkStackIt2-8 70922517 16.0 ns/op 8 B/op 1 allocs/op

那么这是否意味着指针一定会创建分配?让我们再次修改程序,这次将指针传到栈下。

1
2
3
4
5
6
7
8
9
10
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
}

然而运行基准测试显示没有任何东西被分配到堆中。

1
2
$ go test -bench . -benchmem
BenchmarkStackIt3-8 705347884 1.62 ns/op 0 B/op 0 allocs/op

编译器的输出明确地告诉我们这一点。

1
2
3
$ go build -gcflags '-m -l'
# github.com/Jimeux/go-samples/allocations
./main.go:10:14: y does not escape

为什么会出现这种看似不一致的情况呢?stackIt2y 的地址从栈上传递到 main,在 main 中,y 将在 stackIt2 的栈帧被释放后被引用。因此,编译器能够判断 y 必须被移到堆上才能保持活力。如果它不这样做,当我们试图引用 y 时,就会在 main 中得到一个 nil 指针。

stackIt3 则是将 y 传到栈下,而且 ymain3 之外的任何地方都不会被引用。因此,编译器能够判断 y 可以单独存在于栈中,而不需要分配到堆中。在任何情况下,我们都无法通过引用 y 来产生一个 nil 指针。

从这里我们可以推断出一个通用规则,即在栈上共享指针会导致分配,而共享栈下的指针则不会。但是,这并不能保证,所以您仍然需要使用 gcflags 或基准来验证。我们可以肯定的是,任何试图减少 allocs/op 的尝试都将涉及到寻找任性的指针。

我们为什么要关心堆分配?

我们已经了解了一些关于 allocs/op 中的 alloc 的含义,以及如何验证是否触发了对堆的分配,但是为什么我们要关心这个统计是否是非零呢?我们已经做的基准测试可以回答这个问题。

1
2
3
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 的开销还是很明显的。我们可以通过生成 stackItstackIt2 生成的 CPU 曲线的火焰图来了解更多的情况。
stackIt CPU profile

stackIt2 CPU profile

stackIt 有一个不起眼的配置文件,它可以预见地从调用栈运行到 stackIt 函数本身。另一方面,stackIt2 大量使用了大量的运行时函数,这些函数消耗了许多额外的 CPU 周期。这说明了分配到堆所涉及的复杂性,并初步了解了每个操作额外的 10 纳秒左右的去向。

那在现实世界中呢?

如果没有生产条件,性能的许多方面不会变得明显。你的单个功能可能在微基准测试中高效运行,但当它为成千上万的并发用户服务时,它会对你的应用程序有什么影响呢?

我们不会在这篇文章中重新创建一个完整的应用程序,但我们将使用跟踪工具来看看一些更详细的性能诊断。让我们首先定义一个(有点)大的结构体,它有 9 个字段。

1
2
3
4
5
type BigStruct struct {
A, B, C int
D, E, F string
G, H, I bool
}

现在我们来定义两个函数:CreateCopy,它在栈帧之间复制 BigStruct 实例;CreatePointer,它在栈上共享 BigStruct 指针,避免复制,但会产生堆分配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//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,
}
}

我们可以用目前使用的技术来验证上面的解释。

1
2
3
4
5
6
$ 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

以下是我们将用于跟踪工具的测试。它们分别用各自的 Create 函数创建 20,000,000 个 BigStruct 实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
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 中。并在浏览器中用跟踪工具打开它。

1
2
3
4
5
6
7
8
$ 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 功能的火焰图一样不引人注目。8 个潜在的逻辑核(Procs)中只有 2 个被使用,goroutine G19 几乎花费整个时间运行我们的测试循环–这正是我们想要的。
Trace for 20,000,000 CreateCopy calls

让我们为 CreatePointer 代码生成跟踪数据。

1
2
3
4
5
6
7
8
$ 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

您可能已经注意到,与 CreateCopy 的 0.281 秒相比,测试花费了 2.224 秒,选择 View trace 这次显示的内容更加丰富多彩,更加繁忙。所有的逻辑内核都被利用了,堆操作、线程和 goroutines 似乎比上次多了很多。
Trace for 20,000,000 CreatePointer calls

如果我们把跟踪的时间放大到一毫秒左右的跨度,我们会看到很多 goroutine 在执行与垃圾回收相关的操作。前面引用的 FAQ 中使用了“垃圾回收堆”这个词,因为垃圾回收器的工作就是清理堆上任何不再被引用的东西。
在CreatePointer跟踪中的GC活动特写

尽管 Go 的垃圾回收器效率越来越高,但这个过程并不是免费的。我们可以从上面的跟踪输出中直观地验证,测试代码有时完全停止了。对于 CreateCopy 来说,情况并非如此,因为我们所有的 BigStruct 实例仍然在栈上,GC 几乎没有什么事情可做。

比较两组跟踪数据中的 goroutine 分析可以更深入地了解这一点。CreatePointer(底部)花费了超过 15% 的执行时间来清扫或暂停(GC)和调度 goroutines。
CreateCopy 的顶层 goroutine 分析

CreatePointer 的顶层 goroutine 分析

看看跟踪数据中其他地方的一些统计数据,可以进一步说明堆分配的成本,生成的 goroutine数量有明显的差异,CreatePointer 测试有近 400 个 STW(停止世界)事件。

1
2
3
4
5
6
7
8
9
+------------+------+---------+
| | 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 中编写代码也不例外。

然而,如果我们确实有严格的性能要求或在其他方面确定了程序中的瓶颈,这里介绍的概念和工具有望成为进行必要优化的有用起点。

如果你想玩玩这篇文章中的简单代码示例,请查看 GitHub 上的源代码和 README。

微信订阅号

译自:https://medium.com/eureka-engineering/understanding-allocations-in-go-stack-heap-memory-9a2631b5035d

-------------本文结束感谢您的阅读-------------