Go语言中常见100问题-#95 Not understanding stack vs. heap

Golang
414
0
0
2024-04-25
不了解栈和堆

在Go语言中,变量可以分配在栈上,也可以分配在堆上。栈内存和堆内存有着本质不同,会对数据密集型应用产生重大影响。本文主要讨论编译器将一个变量分配到栈上还是堆上规则。

栈与堆

栈是一种先进后出的数据结构,存储特定的goroutine的所有局部变量。当启动一个goroutine时,会分配2KB的连续内存作为栈空间。但是,栈大小在运行时并不是固定不变的,可以根据需要增加或减少。

调用一个函数时,会创建一个栈帧,表示内存中只有当前函数可以访问的区域。下面通过代码进行说明。

代码语言:javascript

复制

func main() {
 a := 3
 b := 2

 c := sumValue(a, b)
 println(c)
}

//go:noinline
func sumValue(x, y int) int {
 return x + y
}

上述代码有两个注意事项,一是使用println内置函数替代fmt.Println,这样强制在堆上分配变量c, 二是sumValue函数禁止内联,通过 //go:noinline,否则sumValue函数被内联后,无函数调用栈。

下图显示变量a和b分配后栈结构,即main函数栈结构。此时,变量a和b已分配有效地址,并存储有对应的数据。

当程序运行到 c:=sumValue(a,b)时,会创建一个新的栈帧,新栈帧中会为变量x,y,z分配相应内存。虽然main栈当前还有效,但是我们不能访问它,因为它不是栈顶帧,也就是说在当前的sumValue函数栈中,不能直接访问main栈中的变量a和b.

当执行完sumValue函数,此时内存中的栈布局如下。内存中已没有sumValue栈帧,main栈中分配了变量c保存sumValue函数的返回值。变量c覆盖了sumValue中变量x的内存,尽管变量y和z的内容未被擦出,但是它们不能访问到。

栈帧使用完后没有从内存中删除,当一个函数调用返回时,Go语言不需要花时间去释放变量来回收空闲空间。但是这些之前的变量不能再被访问,当调用新的函数压栈时,会替换掉之前分配的内容。从某种意义上来说,栈不需要清理,不需要额外的像GC处理。

现在对前面的程序做一点修改,将sumValue函数返回的类型从int改为 *int.

代码语言:javascript

复制

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
}

如果sumPtr中的变量z在栈上分配会产生啥问题?因为c引用的是z变量的地址,而z在栈上分配,当sumPtr调用完后,它不在是一个有效的地址,此外main函数的栈帧继续增长并擦出z变量。在栈上分配z存在这么多问题,需要另一种类型的存储方式:堆存储。

堆内存是由所有 goroutines 共享的内存池。下面中三个goroutine G1、G2和G3都有自己的栈,但它们都共享同一个堆。

上面例子中变量z不能在栈上分配,需要逃逸到堆里。如果在函数调用返回后,编译器不能证明变量没有被引用,那么需要将该变量分配到堆中。

作为开发人员我们需要关心这些嘛,需要关心。理解清楚栈和堆区别,对提升程序性能很有帮助。正如前面所说,栈使用完无需释放内存。相反,堆内存需要进行垃圾回收。分配的堆中内容越多,给GC造成的压力越大。当GC运行时,会使用25%的可用CPU资源,并可能产生毫秒级的“stop the world”延迟。此外在栈上分配对于Go运行时来说更快,因为它很简单(一个指针引用下面的可用内存地址,栈内存空间是连续的,下面的就是未被使用的空间)。但是在堆上需要花费一定时间才能找到正确的位置。

通过编写sumValue和sumPtr基准测试,进一步加深栈和堆对程序影响理解。

代码语言:javascript

复制

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)
 }
 globalPtr = local
}

运行上面的性能测试代码,得到以下结果,可以看到sumPtr比sumValue大约慢了一个数量级。通过这个例子表明使用指针并不一定比值复制更快,需要具体情况具体分析。本系列文章到目前为止只是从语义层面讨论了值和指针:当值必须被共享时使用指针。在大多数情况下,遵循这个规则是没有问题的。此外,我们需要知道现代CPU复制数据的效率非常高,特别是在同一个缓存行中,我们要避免过早优化,首先要关注的是程序可读性和语义。

上面的基准测试代码,调用了 b.ReportAllocs(), 它可以统计堆分配情况。B/op表示每次操作的字节数, allocs/op表示每次操作分配的内存次数。

代码语言:javascript

复制

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkSumValue-4     760946635                1.483 ns/op           0 B/op          0 allocs/op
BenchmarkSumPtr-4       77504274                13.61 ns/op            8 B/op          1 allocs/op
逃逸分析

逃逸分析属于编译器的工作,决定将变量分配在栈上还是堆上。当一个变量不能在栈上完成分配时,将在堆上分配。尽管这条规则非常简单,牢记这点很有必要。例如,如果编译器不能证明函数返回后变量没有被引用,那么这个变量就被分配到堆上。在前面程序中,sumPtr函数返回了一个函数内部的指针变量,一般来说,这种向上共享会分配到堆中。

但是反之是啥情况呢?如果函数接收一个指针,如下程序,也会分配到堆中吗?

代码语言:javascript

复制

func main() {
 a := 3
 b := 2
 c := sum(&a, &b)
 println(c)
}

//go:noinline
func sum(x, y *int) int {
 return *x + *y
}

尽管x和y指向的内容是另一个栈帧中的内容,但是它们都是有效的地址,因为调用sum栈时,main栈是完整的。所以变量a和b无需逃逸到堆中,一般来说,向下共享会分配到栈中。

下面总结了一些常见的变量逃逸到堆上的情况:

  • 全局变量,因为多个goroutines都可以访问它们.
  • 发送到通道的指针,如下程序中的foo逃逸到了堆里.

代码语言:javascript

复制

type Foo struct {
 s string
}

ch := make(chan *Foo, 1)
foo := &Foo{s: "x"}
ch <- foo
  • 发送到通道的值所引用的变量,下面程序中的变量s通过地址被Foo引用,会被分配到堆中.

代码语言:javascript

复制

type Foo struct {
 s *string
}

ch := make(chan Foo, 1)
s := "x"
bar := Foo{s: &s}
ch <- bar
  • 如果局部变量很大,无法在栈中存储,也会被分配到堆中.
  • 如果一个局部变量的大小未知,例如,s:=make([]int,10),可能不会逃逸,但 s:=make([]int,n)会逃逸,因为它的大小跟变量n的大小有关.
  • 使用append操作,重新分配切片后底层数组也会逃逸.

上述总结的变量逃逸规则只是为我们提供了一个思路,可能并不是一直都是这样,随着Go版本更新可能有新变化。如果想确切知道一个变量是否真的逃逸,可以在build时使用 -gcflags查看编译器决定, 如下提示变量z逃逸到堆中。

代码语言:javascript

复制

$ go build -gcflags "-m=2"
...
./main.go:12:2: z escapes to heap:

理解堆和栈之间的根本区别对于优化Go应用程序非常重要,正如前面看到的,堆上分配对于Go运行时更加复杂,需要有GC来回收垃圾释放不在使用的内存。在一些数据密集型应用中,堆管理会占用20%或30%的总CPU时间。相比起来,栈上分配数据无需进行回收,并且对于单个goroutine来说在本地分配,效率非常高。因此,合理优化内存分配有很大投入产出比。

了解逃逸规则可以编写出更高效的代码。一般来说,向下共享在栈上分配,而向上共享则转移到堆上分配。掌握这些知识可以防止犯常识性错误,例如为了避免拷贝,函数返回指针而不是对象,通过前面的性能测试可以看到,这种处理效果反而不好。我们在编写程序时,应该首先关注可读性和语义正确,然后才是根据需要优化分配。