GoLang-逃逸分析

对于手动管理内存的语言,比如 C/C++,调用著名的malloc和new函数可以在堆上分配一块内存,这块内存的使用和销毁的责任都在程序员。一不小心,就会发生内存泄露,搞得胆战心惊。

但是 Golang 并不是这样,虽然 Golang 语言里面也有 new。Golang 编译器决定变量应该分配到什么地方时会进行逃逸分析。使用new函数得到的内存不一定就在堆上。堆和栈的区别对程序员“模糊化”了,当然这一切都是Go编译器在背后帮我们完成的。一个变量是在堆上分配,还是在栈上分配,是经过编译器的逃逸分析之后得出的结论。

一、 逃逸分析是什么

wiki定义

In compiler optimization, escape analysis is a method for determining the dynamic scope of pointers – where in the program a pointer can be accessed. It is related to pointer analysis and shape analysis.

When a variable (or an object) is allocated in a subroutine, a pointer to the variable can escape to other threads of execution, or to calling subroutines. If an implementation uses tail call optimization (usually required for functional languages), objects may also be seen as escaping to called subroutines. If a language supports first-class continuations (as do Scheme and Standard ML of New Jersey), portions of the call stack may also escape.

If a subroutine allocates an object and returns a pointer to it, the object can be accessed from undetermined places in the program — the pointer has “escaped”. Pointers can also escape if they are stored in global variables or other data structures that, in turn, escape the current procedure.

Escape analysis determines all the places where a pointer can be stored and whether the lifetime of the pointer can be proven to be restricted only to the current procedure and/or threa.

C/C++中,有时为了提高效率,常常将pass-by-value(传值)“升级”成pass-by-reference,企图避免构造函数的运行,并且直接返回一个指针。然而这里隐藏了一个很大的坑:在函数内部定义了一个局部变量,然后返回这个局部变量的地址(指针)。这些局部变量是在栈上分配的(静态内存分配),一旦函数执行完毕,变量占据的内存会被销毁,任何对这个返回值作的动作(如解引用),都将扰乱程序的运行,甚至导致程序直接崩溃。例如:

int *foo ( void )   
{   
    int t = 3;
    return &t;
}

为了避免这个坑,有个更聪明的做法:在函数内部使用new函数构造一个变量(动态内存分配),然后返回此变量的地址。因为变量是在堆上创建的,所以函数退出时不会被销毁。但是,这样就行了吗?new出来的对象该在何时何地delete呢?调用者可能会忘记delete或者直接拿返回值传给其他函数,之后就再也不能delete它了,也就是发生了内存泄露。关于这个坑,大家可以去看看《Effective C++》条款21,讲得非常好!

C++是公认的语法最复杂的语言,据说没有人可以完全掌握C++的语法。而这一切在Go语言中就大不相同了。像上面示例的C++代码放到Go里,没有任何问题。

你表面的光鲜,一定是背后有很多人为你撑起的!Go语言里就是编译器的逃逸分析。它是编译器执行静态代码分析后,对内存管理进行的优化和简化。

在编译原理中,分析指针动态范围的方法称之为逃逸分析。通俗来讲,当一个对象的指针被多个方法或线程引用时,我们称这个指针发生了逃逸。

更简单来说,逃逸分析决定一个变量是分配在堆上还是分配在栈上。

二、 为什么要逃逸分析

前面讲的C/C++中出现的问题,在Go中作为一个语言特性被大力推崇。真是C/C++之砒霜Go之蜜糖!

C/C++中动态分配的内存需要我们手动释放,导致猿们平时在写程序时,如履薄冰。这样做有他的好处:程序员可以完全掌控内存。但是缺点也是很多的:经常出现忘记释放内存,导致内存泄露。所以,很多现代语言都加上了垃圾回收机制。

Go的垃圾回收,让堆和栈对程序员保持透明。真正解放了程序员的双手,让他们可以专注于业务,“高效”地完成代码编写。把那些内存管理的复杂机制交给编译器,而程序员可以去享受生活。

逃逸分析这种“骚操作”把变量合理地分配到它该去的地方,“找准自己的位置”。即使你是用new申请到的内存,如果我发现你竟然在退出函数后没有用了,那么就把你丢到栈上,毕竟栈上的内存分配比堆上快很多;反之,即使你表面上只是一个普通的变量,但是经过逃逸分析后发现在退出函数之后还有其他地方在引用,那我就把你分配到堆上。真正地做到“按需分配”,提前实现共产主义!

如果变量都分配到堆上,堆不像栈可以自动清理。它会引起Go频繁地进行垃圾回收,而垃圾回收会占用比较大的系统开销(占用CPU容量的25%)。

堆和栈相比,堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片。栈内存分配则会非常快。栈分配内存只需要两个CPU指令:“PUSH”和“RELEASE”,分配和释放;而堆分配内存首先需要去找到一块大小合适的内存块,之后要通过垃圾回收才能释放。

通过逃逸分析,可以尽量把那些不需要分配到堆上的变量直接分配到栈上,堆上的变量少了,会减轻分配堆内存的开销,同时也会减少gc的压力,提高程序的运行速度。

三、 逃逸分析如何完成

Go逃逸分析最基本的原则是:如果一个函数返回对一个变量的引用,那么它就会发生逃逸。

简单来说,编译器会分析代码的特征和代码生命周期,Go中的变量只有在编译器可以证明在函数返回后不会再被引用的,才分配到栈上,其他情况下都是分配到堆上。

Go语言里没有一个关键字或者函数可以直接让变量被编译器分配到堆上,相反,编译器通过分析代码来决定将变量分配到何处。

对一个变量取地址,可能会被分配到堆上。但是编译器进行逃逸分析后,如果考察到在函数返回后,此变量不会被引用,那么还是会被分配到栈上。

简单来说,编译器会根据变量是否被外部引用来决定是否逃逸:

1)如果函数外部没有引用,则优先放到栈中;

2) 如果函数外部存在引用,则必定放到堆中;

针对第一条,可能放到堆上的情形:定义了一个很大的数组,需要申请的内存过大,超过了栈的存储能力。

四、 逃逸分析实例

下面是一个简单的例子。

package main

import ()

func foo() *int {
    var x int
    return &x
}

func bar() int {
    x := new(int)
    *x = 1
    return *x
}

func main() {}

开启逃逸分析日志很简单,只要在编译的时候加上-gcflags ‘-m’,但是我们为了不让编译时自动内连函数,一般会加-l参数,最终为-gcflags ‘-m -l’,执行如下命令:

$ go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:5:9: &x escapes to heap
./main.go:4:6: moved to heap: x
./main.go:9:10: bar new(int) does not escape

上面代码中foo() 中的 x 最后在堆上分配,而 bar() 中的 x 最后分配在了栈上。

也可以使用反汇编命令看出变量是否发生逃逸。

$ go tool compile -S main.go

截取部分结果,图中标记出来的说明foo中x是在堆上分配内存,发生了逃逸。

反汇编命令结果

什么时候逃逸呢? golang.org FAQ 上有一个关于变量分配的问题如下:

Q: How do I know whether a variable is allocated on the heap or the stack?

A: From a correctness standpoint, you don’t need to know. Each variable in Go exists as long as there are references to it. The storage location chosen by the implementation is irrelevant to the semantics of the language.

The storage location does have an effect on writing efficient programs. When possible, the Go compilers will allocate variables that are local to a function in that function’s stack frame. However, if the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap to avoid dangling pointer errors. Also, if a local variable is very large, it might make more sense to store it on the heap rather than the stack.

In the current compilers, if a variable has its address taken, that variable is a candidate for allocation on the heap. However, a basic escape analysis recognizes some cases when such variables will not live past the return from the function and can reside on the stack.

关于什么时候逃逸,什么时候不逃逸,我们接下来再看几个小例子。

1)Example1

package main
type S struct{}
func main() {
    var x S
    y := &x
    _ = *identity(y)
}
func identity(z *S) *S {
    return z
}

结果如下:

# command-line-arguments
./main.go:8:22: leaking param: z to result ~r1 level=0
./main.go:5:7: main &x does not escape

这里的第一行表示z变量是“流式”,因为identity这个函数仅仅输入一个变量,又将这个变量作为返回输出,但identity并没有引用z,所以这个变量没有逃逸,而x没有被引用,且生命周期也在mian里,x没有逃逸,分配在栈上。

2)Example2

package main
type S struct{}
func main() {
    var x S
    _ = *ref(x)
}
func ref(z S) *S {
    return &z
}

结果如下:

# command-line-arguments
./main.go:8:9: &z escapes to heap
./main.go:7:16: moved to heap: z

这里的z是逃逸了,原因很简单,go都是值传递,ref函数copy了x的值,传给z,返回z的指针,然后在函数外被引用,说明z这个变量在函数內声明,可能会被函数外的其他程序访问。所以z逃逸了,分配在堆上

3)Example3

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 
}

结果如下:

# command-line-arguments
./main.go:10:8: &y escapes to heap
./main.go:9:26: moved to heap: y

看日志的输出,这里的y是逃逸了,看来在struct里好像并没有区别,有可能被函数外的程序访问就会逃逸

4)Example4

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 
}

结果如下:

# command-line-arguments
./main.go:9:27: leaking param: y to result z level=0
./main.go:7:12: main &i does not escape

这里的y没有逃逸,分配在栈上,原因和Example1是一样的。

5)Example5

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
}

结果如下:

# command-line-arguments
./main.go:10:21: leaking param: y
./main.go:10:21: ref z does not escape
./main.go:8:6: &i escapes to heap
./main.go:7:6: moved to heap: i
./main.go:8:10: main &x does not escape

这里的z没有逃逸,而i却逃逸了,这是因为go的逃逸分析不知道z和i的关系,逃逸分析不知道参数y是z的一个成员,所以只能把它分配给堆。