前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Go语言中常见100问题-#95 Not understanding stack vs. heap

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

作者头像
数据小冰
发布2024-01-22 17:32:55
970
发布2024-01-22 17:32:55
举报
文章被收录于专栏:数据小冰数据小冰
不了解栈和堆

在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来说在本地分配,效率非常高。因此,合理优化内存分配有很大投入产出比。

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

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2024-01-22,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 数据小冰 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 不了解栈和堆
    • 栈与堆
      • 逃逸分析
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档