在 Go 中,变量可以分配在栈上或堆上。这两种类型的内存根本不同,可以显着影响数据密集型应用程序。让我们首先深入研究这些概念;然后,我们将看到编译器遵循的规则来决定应该在哪里分配变量。
首先,让我们讨论一下栈和堆之间的区别。
栈是默认内存,它是一个 LIFO 数据结构,用于存储特定 goroutine 的所有局部变量。当一个 goroutine 启动时,它会获得 2 KB 的连续内存来形成它的栈空间(这个大小在过去已经演变,并且可能会再次改变)。但是,这个大小在运行时不是固定的,可以根据需要增长和缩小(但它在内存中将始终保持连续,保留数据局部性)。
当 Go 进入一个函数时,会创建一个栈帧,表示内存中只有当前函数可以访问的间隔。
让我们深入研究一个具体的例子来理解栈帧的概念。在这里,main
函数将打印 sumValue
函数的结果:
func main() {
a := 3
b := 2
c := sumValue(a, b)
println(c)
}
//go:noinline
func sumValue(x, y int) int {
z := x + y
return z
}
这里需要注意两点。首先,我们使用 println
内置函数代替 fmt.Println
;否则,它将强制在堆上分配 c
变量。第二点,我们禁用 sumValue
函数的内联;
否则,它不会导致函数调用(我们将在不依赖内联中讨论内联)。
让我们看一下 a
和 b
分配后的栈:
当我们执行 main
时,为这个函数创建了一个栈帧。两个变量 a
和 b
被分配到这个栈帧中的栈中。所有存储的变量都是有效地址,这意味着它们可以被引用和访问。
现在,如果我们进入 sumValue
函数,直到 return
语句,会发生什么:
Go 运行时创建了一个新的栈框架作为当前 goroutine 栈的一部分。x
和 y
在当前栈帧中与 z
一起分配。
先前的栈帧包含仍被视为有效的地址。我们不能直接访问 a
和 b
,但是如果我们在 a
上有一个指针,例如,它将是一个有效的指针。我们将在短时间内讨论指针。
让我们再次移动到应用程序的最后一条语句 println
。我们退出了 sumValue
函数,那么它的栈帧会发生什么?
sumValue
栈帧没有从内存中完全擦除。这意味着当函数返回时,Go 不会花费一些时间来释放变量以回收可用空间。但是,这些以前的变量不能再被访问,当父函数中的新变量被分配到栈时,它们将简单地替换以前的分配。从某种意义上说,栈是自清洁的;它不需要额外的机制,例如 GC。
现在,让我们稍微改变一下以了解栈的局限性。该函数将返回一个指针,而不是返回一个 int
:
func main() {
a := 3
b := 2
c := sumPtr(a, b)
println(*c)
}
//go:noinline
func sumPtr(x, y int) *int {
z := x + y
return &z
}
main
中的 c
变量现在是 *int
类型。让我们直接转到调用 sumPtr
之后的最后一个 println
语句。如果 z
仍然分配在栈上会发生什么(正如我们所理解的那样,情况不可能如此):
如果 c
引用 z
变量的地址并且 z
分配在栈上,那将是一个主要问题。该地址将不再有效,而且 main
的栈框架还在不断增长;它会擦除 z
变量。因此,栈是不够的,我们需要另一种类型的内存:堆。
内存堆是所有 goroutine 共享的内存池:
在这里,三个 goroutines G1
, G2
, 和 G3
有自己的栈。同时,它们都共享同一个堆。
在前面的示例中,我们已经看到 z
变量不能存在于栈中;因此,它将被转义到堆中。实际上,如果编译器无法证明函数返回后未引用某个变量,则会在堆上分配该变量。
但我们到底为什么要关心呢?了解栈和堆之间的区别有什么意义?因为在性能方面有重大影响。
正如我们所说,栈是自清洁的,并由单个 goroutine 访问。相反,堆必须由外部系统清理:GC。因此,堆分配越多,我们对 GC 的压力就越大。当 GC 运行时,它将使用 25% 的可用 CPU 容量并可能产生毫秒级的“停止世界”延迟。
此外,我们必须了解,在 Go 运行时分配栈更快,因为它是微不足道的:指针引用以下可用内存地址。相反,在堆上分配需要更多的努力才能找到正确的位置;因此,需要更多的时间。
为了说明这些差异,让我们对 sumValue
和 sumPtr
进行基准测试:
var globalValue int
var globalPtr *int
func BenchmarkSumValue(b *testing.B) {
b.ReportAllocs()
var local int
for i := 0; i < b.N; i++ {
local = sumValue(i, i)
}
globalValue = local
}
func BenchmarkSumPtr(b *testing.B) {
b.ReportAllocs()
var local *int
for i := 0; i < b.N; i++ {
local = sumPtr(i, i)
}
globalValue = *local
}
如果我们运行这些基准测试(并且仍然禁用内联),我们将得到以下结果:
BenchmarkSumValue-4 992800992 1.261 ns/op 0 B/op 0 allocs/op
BenchmarkSumPtr-4 82829653 14.84 ns/op 8 B/op 1 allocs/op
我们可以注意到,sumPtr
比 sumValue
慢一个数量级,这是使用堆而不是栈的直接结果。
Note 这个例子表明使用指针来避免复制不一定更快;这取决于上下文。
到目前为止,在本书中,我们只通过语义棱镜讨论了值与指针:在必 须共享值时使用指针。在大多数情况下,它应该是遵循的规则。
还要记住,现代 CPU 在复制数据方面非常高效,尤其是在同一高速缓存行中。让我们避免过早的优化,首先关注可读性和语义。
我们还要注意,在之前的基准测试中,我们调用了 b.ReportAllocs()
,它突出显示了堆分配(不计算栈分配):
B/op
:每个操作分配多少字节allocs/op
:每个操作有多少分配
现在让我们深入研究变量逃逸到堆的条件。
逃逸分析是指编译器执行的工作来决定一个变量应该分配在栈还是堆 上。让我们了解主要规则。
首先,当一个分配不能在栈上完成时,它会在堆上完成。尽管这听起来像是一个简单的规则,但记住它很重要。例如,如果编译器无法证明函数返回后没有引用变量,则该变量将分配到堆上。这就是 sumPtr
函数返回指向在函数范围内创建的变量的指针的情况。一般来说,共享逃逸到堆中。
但相反的呢?如果我们接受一个指针,如下例所示:
func main() {
a := 3
b := 2
c := sum(&a, &b)
println(c)
}
//go:noinline
func sum(x, y *int) int {
return *x + *y
}
sum
接受在父级中创建的变量的两个指针。如果我们移动到 sum
函数中的 return
语句,这是当前栈的表示:
尽管是另一个栈帧的一部分,但 x
和 y
变量引用了有效地址。因此,a
和 b
不必转义;他们可以留在栈上。通常,向下共享 保留在栈上。
变量可以转义到堆的其他情况呢?
- 全局变量作为多个 goroutine 可以访问它们。
- 发送到通道的指针:
type Foo struct{ s string }
ch := make(chan *Foo, 1)
foo := &Foo{s: "x"}
ch <- foo
在这里,foo
将逃到堆中。
- 由发送到通道的值引用的变量
type Foo struct{ s *string }
ch := make(chan Foo, 1)
s := "x"
bar := Foo{s: &s}
ch <- bar
由于 Foo
通过其地址引用了 s
,它将转义到堆。
- 如果局部变量太大而无法放入栈。
- 如果局部变量的大小未知。例如,
s := make([]int, 10)
可能不会转义到堆,但s := make([]int, n)
会转义,因为它的大小基于变量。 - 使用
append
重新分配的切片的后备数组。
虽然这个列表可以为我们提供理解编译器决定的想法,但它并不详尽,并且在未来的 Go 版本中也会发生变化。如果我们想确认一个假设,我们可以使用 ‑gcflags
访问编译器的决定:
$ go build -gcflags "-m=2"
...
./main.go:12:2: z escapes to heap:
在这里,编译器通知我们 z
变量将逃逸到堆中。
了解堆和栈之间的根本区别对于优化 Go 应用程序至关重要。正如我们所看到的,堆分配对于 Go 运行时来说处理起来更加复杂,并且需要一个带有 GC 的外部系统来释放数据。在某些数据密集型应用程序中,堆管理最多可占总 CPU 时间的 20% 到 30%。同时,栈是自清理的,并且对于单个 goroutine 来说是本地的,从而使分配更快。因此,优化内存分配可以有很大的投资回报。
此外,了解转义分析规则以编写更高效的代码至关重要。一般来说,向下共享会留在栈上,而向上共享会逃到堆上。这应该可以防止常见错误,例如我们可能想要返回指针的过早优化,例如,因为“它避免了复制”。让我们首先关注可读性和语义,然后在需要时优化分配。
下一节将讨论如何减少分配。