go语言变量逃逸分析

go实现了内存的自动管理,其主要包括两个动作:分配与释放。为了更好的理解逃逸分析,需要对堆和栈有一定的了解。

堆和栈

应用程序的内存载体,可以简单分为堆和栈。

在go中,栈的内存是由编译器进行分配和释放,栈区往往存储着函数参数、局部变量和调用函数帧,它们随着函数的创建而分配,函数的退出而销毁。一个goroutine对应一个栈,栈是调用栈的简称。一个栈通常又包含了许多栈帧,它描述的函数之间的调用关系,每一帧对应一次尚未返回的函数调用,它本身也是以栈形式存放数据。

与栈不同的是,应用程序在运行时只会存在一个堆。狭隘的说,内存管理只是针对堆内存而言的。程序在运行期间可以主动从堆上申请内存,这些内存通过go的内存分配器分配,并由垃圾回收器回收。

另外,对于堆内存的回收,还需要通过标记清除阶段,如三色标记法。但是对于栈上的内存而言,其分配和释放非常廉价。简单的说,它只需要两个cpu指令,一个是分配入栈,一个是栈内释放,而这只需要借助栈相关的寄存器即可完成。

逃逸分析

对于一个对象是被分配在堆上还是栈上,官网上也有这样的回答:

  • 如果可以,go编译器会尽可能将变量分配到栈上。但是,当编译器无法证明函数返回后,该变量没有被引用,那么编译器就必须在堆上分配该变量,以此避免悬挂指针。另外如果局部变量非常大,也会将其分配在堆上。
  • 而go编译器则是通过逃逸分析去选择堆或者是栈,逃逸分析的基本思想如下:检查变量的生命周期是否是完全可知的,如果通过检查,则可以在栈上分配。否则,就是逃逸,必须在堆上进行分配。

可以通过命令go build -gcflags "-m -l"来查看逃逸分析结果:

  • -m:打印逃逸分析信息
  • -l:禁止内联优化

常见的逃逸情况如下所示:

情况一:变量类型不确定

1
2
3
4
5
6
7
8
9
10
func main() {
a := 666
fmt.Println(a)
}

// 输出如下
// $ go build -gcflags "-m -l" main.go
// # command-line-arguments
// ./main.go:7:13: ... argument does not escape
// ./main.go:7:13: a escapes to heap

变量a发生了逃逸是因为其被传入了fmt.Println中,这个方法参数自己发生了逃逸。

  • func Println(a ...interface{}) (n int, err error)
  • 因为fmt.Println函数参数为interface类型,编译期无法确定参数的具体类型,故分配在堆上

情况二:暴露给外部指针

1
2
3
4
5
6
7
8
9
10
11
12
func foo() *int {
a := 666
return &a
}

func main() {
_ = foo()
}

// # command-line-arguments
// .\main.go:4:2: moved to heap: a

变量存在外部引用则必定分配到堆上。

情况三:变量所占内存较大

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func foo() {
s := make([]int, 10000, 10000)
for i := 0; i < len(s); i++ {
s[i] = i
}
}

func main() {
foo()
}

// # command-line-arguments
// .\main.go:4:11: make([]int, 10000, 10000) escapes to heap

这里需要注意,在go中执行用户代码的goroutine是一种用户态线程,其调用栈内存被称为用户栈,它其实也是从堆区分配的,但是我们仍然可以将其看作和系统栈一样的内存空间,它的分配和释放都是通过编译器完成的。与其对应的是系统栈,它的分配和释放是操作系统完成的。在GMP模型中,一个M对应一个系统栈(也称为M的g0栈),M上的多个goroutine会共享该系统栈。

  • 不同架构的系统栈最大限制不同,以x86_64为例,其系统栈最大为8mb

  • 我们常说的goroutine初始大小为2kb,说的是用户栈,可在runtime/stack.go中找到

  • 在go中大对象的范围为大于32kb,即上述代码中的n达到8192,就会逃逸

    情况四:变量大小不确定

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    func foo() {
    n := 1
    s := make([]int, n)
    for i := 0; i < len(s); i++ {
    s[i] = i
    }
    }

    func main() {
    foo()
    }

    // # command-line-arguments
    // .\main.go:5:11: make([]int, n) escapes to heap

    这次,在make方法中,没有直接指定大小,而是填入了变量n,这时go逃逸分析也会将其分配到堆区去。可见,为了保证内存的绝对安全,go的编译器可能会将一些变量不合时宜地分配到堆上,但是因为这些对象最终也会被垃圾收集器处理,所以也能接受。

    小结

  • 发生逃逸的情况还有很多,理解其思想才是最为重要的。

  • 理解逃逸分析可以帮助我们写出更好的程序,知道变量分配在堆栈上的差别,则尽可能写出分配在栈上的代码,堆上的变量变少了,可以减轻内存分配的开销,减小gc的压力,提高程序的运行速度。

  • 你会发现有些go上线项目,它们在函数传参的时候,并没有传递结构体指针,而是直接传递的结构体。这个做法,虽然它需要值拷贝,但是这是在栈上完成的操作,开销远比变量逃逸后动态的在堆上分配内存少的多。当然这个做法不是绝对的,如果结构体较大,传递指针将更加合适。

  • 从gc的角度来看,指针传递是个双刃剑,需要谨慎使用,否则线上调优解决gc延时会让人崩溃。