golang逃逸分析

2023-07-30 16:43:25 浏览数 (1)

堆内存与栈内存

Go 程序会在 2 个地方为变量分配内存,一个是全局的堆(heap)空间用来动态分配内存,另一个是每个 goroutine 的栈(stack)空间。与 Java、Python 等语言类似,Go 语言实现垃圾回收(Garbage Collector)机制,因此,Go 语言的内存管理是自动的,通常开发者不需要关心内存分配在栈上,还是堆上。但是从性能的角度出发,在栈上分配内存和在堆上分配内存,性能差异是非常大的。

  • 栈的内存是由编译器自动进行分配和释放的,栈区往往存储着函数参数、局部变量和调用函数帧,它们随着函数的创建而分配,随着函数的退出而销毁。
  • Go应用程序运行时,每个 goroutine 都维护着一个自己的栈区,这个栈区只能自己使用不能被其他 goroutine 使用。(所以不需要加锁)栈是调用栈(call stack)的简称。一个栈通常又包含了许多栈帧(stack frame),它描述的是函数之间的调用关系
  • 堆区的内存一般由编译器和工程师自己共同进行管理分配,交给 Runtime GC 来释放。在堆上分配时,必须找到一块足够大的内存来存放新的变量数据。后续释放时,垃圾回收器扫描堆空间寻找不再被使用的对象。(所有有时候会有加锁的操作防止数据竞争)

在函数中申请一个对象,如果分配在栈中,函数执行结束时自动回收,如果分配在堆中,则在函数结束后某个时间点进行垃圾回收。

在栈上分配和回收内存的开销很低,在栈上分配内存,消耗的仅是将数据拷贝到内存的时间,而内存的 I/O 通常能够达到 30GB/s,因此在栈上分配内存效率是非常高的。

在堆上分配内存,一个很大的额外开销则是垃圾回收。Go 语言使用的是标记清除算法,并且在此基础上使用了三色标记法和写屏障技术,提高了效率。

函数参数是值传递的,且在调用的时立即执行值拷贝的。所以无论传递什么参数都会被copy到函数的参数变量的内存地址中,堆或者栈上,具体是堆还是栈上涉及到逃逸问题

什么是逃逸分析

逃逸分析是编译器用于决定变量分配到堆上还是栈上的一种行为

确定一个变量是在堆上还是在栈上 ?

  1. 是否有在其他地方(非局部)被引用。只要有可能被引用了,那么它一定分配到堆上。否则分配到栈上。
  2. 即使没有被外部引用,但对象过大,无法存放在栈区上。依然有可能分配到堆上。

比如这样的例子

代码语言:javascript复制
func main() {
    var i int
    fmt.Printf("main: %pn", &i)
    foo(i)
}

func foo(i int)  {
    fmt.Printf("foo : %pn", &i)
}
// 输出的变量地址不一样
main: 0xc0000382b0
foo : 0xc0000382b8

所以对于复杂结构应该尽量的传递指针减少copy时的开销。

指针传递的同时也带来变量逃逸,和GC压力,也是一把双刃剑,好在大部分情况下不需要特别的对GC进行调优。所以,在make it simple的理念下,在需要时再针对性调优是个不错的选择。

什么时候我们应该传递值,什么时候应该传递指针,这主要取决于copy开销和是否需要在函数内部对变量值进行更改。

指针逃逸

指针逃逸应该是最容易理解的一种情况了,即在函数中创建了一个对象,返回了这个对象的指针。这种情况下,函数虽然退出了,但是因为指针的存在,对象的内存不能随着函数结束而回收,因此只能分配在堆上。

代码语言:javascript复制
// main.go
package main

import "fmt"

type Demo struct {
	name string
}

func createDemo(name string) *Demo {
	d := new(Demo) // 局部变量 d 逃逸到堆
	d.name = name
	return d
}

func main() {
	demo := createDemo("demo")
	fmt.Println(demo)
}

这个例子中,函数 createDemo 的局部变量 d 发生了逃逸。d 作为返回值,在 main 函数中继续使用,因此 d 指向的内存不能够分配在栈上,随着函数结束而回收,只能分配在堆上。

编译时可以借助选项 -gcflags=-m,查看变量逃逸的情况:

代码语言:javascript复制
go run -gcflags '-m' main.go   

加 -l 了是为了不让Go 编译时自动内敛函数

代码语言:javascript复制
go run - gcflags '-m -l' escape . go
代码语言:javascript复制
# command-line-arguments
./main.go:13:18: moved to heap: userInfo

GetUserInfo函数里面的变量 userInfo 逃到堆上了(分配到堆内存空间上了)。 GetUserInfo 函数的返回值为 *UserData 指针类型,然后 将值变量userInfo 的地址返回,此时编译器会判断该值可能会在函数外使用,就将其分配到了堆上,所以变量userInfo就逃逸了。

interface{} 动态类型逃逸

在 Go 语言中,空接口即 interface{} 可以表示任意的类型,如果函数参数为 interface{},编译期间很难确定其参数的具体类型,也会发生逃逸。

代码语言:javascript复制
func main() {
	demo := createDemo("demo")
	fmt.Println(demo)
}

demo 是 main 函数中的一个局部变量,该变量作为实参传递给 fmt.Println(),但是因为 fmt.Println() 的参数类型定义为 interface{},因此也发生了逃逸。

对于 Go 语言来说,运行时(runtime) 尝试在 goroutine 需要的时候动态地分配栈空间,goroutine 的初始栈大小为 2 KB。当 goroutine 被调度时,会绑定内核线程执行,栈空间大小最也不会超过操作系统的限制。 对 Go 编译器而言,超过一定大小的局部变量将逃逸到堆上,不同的 Go 版本的大小限制可能不一样。

当切片占用内存超过一定大小,或无法确定当前切片长度时,对象占用内存将在堆上分配。

发生逃逸的几种情况

  • 在某个函数中new或字面量创建出的变量,将其指针作为函数返回值,则该变量一定发生逃逸(构造函数返回的指针变量一定逃逸);
  • 被已经逃逸的变量引用的指针,一定发生逃逸;
  • 被指针类型的slice、map和chan引用的指针,一定发生逃逸;一个典型的例子就是 []*string 。这会导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。
  • slice 的背后数组被重新分配了,因为 append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。

必然不会逃逸

  • 指针被未发生逃逸的变量引用;
  • 仅仅在函数内对变量做取址操作,而未将指针传出;

可能发生逃逸,也可能不会发生逃逸:

  • 将指针作为入参传给别的函数;这里还是要看指针在被传入的函数中的处理过程,如果发生了上边的三种情况,则会逃逸;否则不会逃逸;

一些例子

例1

代码语言:javascript复制
package main

type S struct{}

func main() {
	var x S
	y := &x
	_ = *identity(y)
}
func identity(z *S) *S {
	return z
}
代码语言:javascript复制
 go run -gcflags '-m -l' main.go

# command-line-arguments
./main.go:10:15: leaking param: z to result ~r0 level=0

第一行是z变量是流经某个函数的意思,仅作为函数的输入,并且直接返回,在 identity()中也没有使用到 z的引用,所以变量没有逃逸。第二行, x在 main()函数中声明,所以是在 main()函数中的栈中的,也没有逃逸。

当然要是上面的例子,打印出 *identity(y) 的返回值,那肯定就是逃逸了。比如 例2

代码语言:javascript复制
package main

import "fmt"

type S struct{}

func main() {
	var x S
	y := &x
	c := *identity(y)
	fmt.Println(c)
}
func identity(z *S) *S {
	return z
}
代码语言:javascript复制
 go run -gcflags '-m -l' main.go

# command-line-arguments
./main.go:13:15: leaking param: z to result ~r0 level=0
./main.go:11:13: ... argument does not escape
./main.go:11:14: c escapes to heap

那是否是不引用返回值就不逃逸了呢。不,一样的逃逸的,看下面这个例子

例3

代码语言:javascript复制
package main

type S struct{}

func main() {
	var x S
	_ = *ref(x)
}
func ref(z S) *S { return &z }
代码语言:javascript复制
 go run -gcflags '-m -l' main.go

# command-line-arguments
./main.go:9:10: moved to heap: z

ref()的参数 z是通过值传递的,所以 z是 main()函数中 x的一个值拷贝,而 ref()返回了 z的引用,所以 z不能放在 ref()的栈中,实际上被分配到了堆上。

例4

代码语言:javascript复制
package main

type S struct{ M *int }

func main() {
	var i int
	refStruct(&i)
}
func refStruct(y *int) (z S) {
	z.M = y
	return z
}
代码语言:javascript复制
 go run -gcflags '-m -l' main.go

# command-line-arguments
./main.go:9:16: leaking param: y to result z level=0

这个 y没有逃逸的原因是, main()中带着 i的引用调用了 refStruct()并直接返回了,从来没有超过 main()函数的调用栈

例5

代码语言:javascript复制
package main

type S struct{ M *int }

func main() {
	var x S
	var i int
	ref(&i, &x)
}
func ref(y *int, z *S) { z.M = y }
代码语言:javascript复制
# command-line-arguments
./main.go:10:10: leaking param: y
./main.go:10:18: z does not escape
./main.go:7:6: moved to heap: i

y和 z没有逃逸很好理解,但问题在于 y还被赋值到函数 ref()的输入 z的成员了,而Go的逃逸分析不能跟踪变量之间的关系,不知道 i变成了 x的一个成员,分析结果说 i是逃逸的,但本质上 i是没逃逸的

例6 interface类型逃逸

代码语言:javascript复制
package main

import "fmt"

func main() {
	str := "str"
	fmt.Printf("%p", &str)
}
代码语言:javascript复制
# command-line-arguments
./main.go:6:2: moved to heap: str
./main.go:7:12: ... argument does not escape

str也逃逸到了堆上,在堆上进行内存分配,这是因为访问str的地址,因为入参是interface类型,所以变量str的地址以实参的形式传入fmt.Printf后被装箱到一个interface{}形参变量中,装箱的形参变量的值要在堆上分配,但是还要存储一个栈上的地址,也就是str的地址,堆上的对象不能存储一个栈上的地址,所以str也逃逸到堆上,在堆上分配内存。

例7 闭包发生的逃逸

代码语言:javascript复制
func Increase() func() int {
    n := 0
    return func() int {
        n  
        return n
    }
}

func main() {
    in := Increase()
    fmt.Println(in()) // 1
}
代码语言:javascript复制
# command-line-arguments
./main.go:6:2: moved to heap: n
./main.go:7:9: func literal escapes to heap
./main.go:15:13: ... argument does not escape
./main.go:15:16: in() escapes to heap

函数也是一个指针类型,所以匿名函数当作返回值时也发生了逃逸,在匿名函数中使用外部变量n,这个变量n会一直存在直到in被销毁,所以n变量逃逸到了堆上。

例8 变量大小不确定以及栈空间不足引发逃逸

先使用ulimit -a查看操作系统的栈空间:

代码语言:javascript复制
-t: cpu time (seconds)              unlimited
-f: file size (blocks)              unlimited
-d: data seg size (kbytes)          unlimited
-s: stack size (kbytes)             8192
-c: core file size (blocks)         0
-v: address space (kbytes)          unlimited
-l: locked-in-memory size (kbytes)  unlimited
-u: processes                       2784
-n: file descriptors                256

我的电脑是mac,栈大小是8192

代码语言:javascript复制
package main

import (
    "math/rand"
)

func LessThan8192()  {
    nums := make([]int, 8191) // < 64KB
    for i := 0; i < len(nums); i   {
        nums[i] = rand.Int()
    }
}


func MoreThan8192(){
    nums := make([]int, 8192) // = 64KB
    for i := 0; i < len(nums); i   {
        nums[i] = rand.Int()
    }
}


func NonConstant() {
    number := 10
    s := make([]int, number)
    for i := 0; i < len(s); i   {
        s[i] = i
    }
}

func main() {
    NonConstant()
    MoreThan8192()
    LessThan8192()
}
代码语言:javascript复制
go run -gcflags '-m -l' main.go

# command-line-arguments
./main.go:8:14: make([]int, 100) does not escape
./main.go:15:14: make([]int, 1000000) escapes to heap
./main.go:23:11: make([]int, number) escapes to heap

当栈空间足够时,不会发生逃逸,但是当变量过大时,已经完全超过栈空间的大小时,将会发生逃逸到堆上分配内存。 同样当我们初始化切片时,没有直接指定大小,而是填入的变量,这种情况为了保证内存的安全,编译器也会触发逃逸,在堆上进行分配内存。

例10

代码语言:javascript复制
package main
import "fmt"
type A struct {
    s string
}
// 这是上面提到的 "在方法内把局部变量指针返回" 的情况
func foo(s string) *A {
    a := new(A) 
    a.s = s
    return a //返回局部变量a,在C语言中妥妥野指针,但在go则ok,但a会逃逸到堆
}
func main() {
    a := foo("hello")
    b := a.s   " world"
    c := b   "!"
    fmt.Println(c)
}

例11 变量类型不确定发生的逃逸

代码语言:javascript复制
package main

import "fmt"

func main() {
	a := 123
	fmt.Println(a)
}
代码语言:javascript复制
go run -gcflags '-m -l' main.go

# command-line-arguments
./main.go:8:13: ... argument does not escape
./main.go:8:14: a escapes to heap

变量a逃逸到了堆上。但是我们并没有外部引用,为什么也会有逃逸呢?为了看到更多细节,可以在语句中再添加一个-m参数。

代码语言:javascript复制
 go run -gcflags '-m -m -l' main.go

# command-line-arguments
./main.go:7:14: a escapes to heap:
./main.go:7:14:   flow: {storage for ... argument} = &{storage for a}:
./main.go:7:14:     from a (spill) at ./main.go:7:14
./main.go:7:14:     from ... argument (slice-literal-element) at ./main.go:7:13
./main.go:7:14:   flow: {heap} = {storage for ... argument}:
./main.go:7:14:     from ... argument (spill) at ./main.go:7:13
./main.go:7:14:     from fmt.Println(... argument...) (call parameter) at ./main.go:7:13
./main.go:7:13: ... argument does not escape
./main.go:7:14: a escapes to heap

a逃逸是因为它被传入了fmt.Println的参数中,这个方法参数自己发生了逃逸。因为fmt.Println的函数参数为interface类型,编译期不能确定其参数的具体类型,所以将其分配于堆上。

源码位置

这里就暂时不贴了,可以链接过去直接看

大概就是说

片段中通过定义 labelState 常量和 func 方法来标记不需要增加循环深度的标签,并且给它们赋予 nonlooping 状态。

paramTag 函数,用于向函数参数添加逃逸分析信息。该函数首先获取参数名称,然后检查是否需要为当前函数生成诊断信息,以及该函数是否包含主体语句。

如果函数没有主体语句,则假定 uintptr 参数必须在调用期间保持活动状态,并设置 pragma 表示此信息。接着,如果参数类型不包含指针,则返回空字符串;否则,创建一个新的泄漏对象(leaks object)来表示参数可能逃逸的位置。如果函数被标记为“noescape”,则将堆位置添加到泄漏对象中;否则,在启用诊断的情况下生成一个警告并将堆位置添加到泄漏对象中。对于具有主体的函数,paramTag 函数从旧位置检索参数的现有逃逸分析信息,优化它,并将其分配给 leaks 变量。如果启用了诊断且参数没有逃逸,则会产生警告。如果参数逃逸到结果参数,则将显示带有逃逸级别的警告。最后,函数将泄漏对象编码为字符串并返回。

所以分析了这么多,函数传递指针真的比传值效率高吗?

  • 传递指针可以减少底层值的拷贝,可以提高效率,但是如果拷贝的数据量小,由于指针传递会产生逃逸,可能会使用堆,也可能会增加GC的负担,所以传递指针不一定是高效的。

如果想要减少垃圾回收的时间,提高程序性能,那就要尽量避免在堆上分配空间

总结一下

  • 函数返回变量的指针时,这个变量会逃逸
  • 当觉得栈上的空间不够时,会分配在堆上
  • 在切片上存储指针或带指针的值的时候,对应的变量会逃逸
  • chan里面的元素是指针的时候,也会发生逃逸
  • map的value是指针的时候,也会发生逃逸
  • 在interface类型上调用方法,也会发生逃逸
  • 当给一个slice分配一个动态的空间容量时,也会发生逃逸
  • 函数或闭包外声明指针,在函数或闭包内分配,也会发生逃逸
  • 函数外初始化变量,函数内使用变量,然后返回函数,也会发生逃逸
  • 被已经逃逸的指针引用的指针,也会发生逃逸
  • 逃逸分析在编译阶段完成的

注意

  • go run -gcflags ‘-m -m -l’ xx.main 不一定100%对,详情参考

参考

逃逸分析优化性能的论文 通过实例理解Go逃逸分析 逃逸分析对性能的影响

0 人点赞