逃逸分析

逃逸分析

通常在编译代码的时候,编译器根据分析,判断将变量分配在栈或堆上。函数定义中,一般将局部变量和参数分配到栈上(stack frame)上。但是,如果编译器不能确定在函数返回(return)时,变量是否被引用(reference),分配到堆上;如果局部变量非常大,也应分配在堆上。如果对变量取地址(*和&操作),则有可能分配在堆上。此外,还需要进行逃逸分析(escape analytic),判断 return 后变量是否被引用,不引用分配到栈上,引用分配到堆上。

在 Golang 中逃逸分析是一种确定指针动态范围的方法,可以分析在程序的哪些地方可以访问到指针。它涉及到指针分析和形状分析。当一个变量(或对象)在子程序中被分配时,一个指向变量的指针可能逃逸到其它执行线程中,或者去调用子程序。如果使用尾递归优化(通常在函数编程语言中是需要的),对象也可能逃逸到被调用的子程序中。如果一个子程序分配一个对象并返回一个该对象的指针,该对象可能在程序中的任何一个地方被访问到——这样指针就成功“逃逸”了。如果指针存储在全局变量或者其它数据结构中,它们也可能发生逃逸,这种情况是当前程序中的指针逃逸。逃逸分析需要确定指针所有可以存储的地方,保证指针的生命周期只在当前进程或线程中。

但是 Golang 编译器决定变量应该分配到什么地方时会进行逃逸分析,下面我们看段代码:

package main

import ()

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

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

func main() {}

运行后:

>  go run -gcflags '-m -l' escape.go
./main.go:6: moved to heap: x
./main.go:7: &x escape to heap
./main.go:11: bar new(int) does not escape

foo() 中的 x 最后在堆上分配,而 bar() 中的 x 最后分配在了栈上。如何得知变量是分配在栈(stack)上还是堆(heap)上?

准确地说,你并不需要知道。Golang 中的变量只要被引用就一直会存活,存储在堆上还是栈上由内部实现决定而和具体的语法没有关系。

知道变量的存储位置确实和效率编程有关系。如果可能,Golang 编译器会将函数的局部变量分配到函数栈帧(stack frame)上。然而,如果编译器不能确保变量在函数 return 之后不再被引用,编译器就会将变量分配到堆上。而且,如果一个局部变量非常大,那么它也应该被分配到堆上而不是栈上。

当前情况下,如果一个变量被取地址,那么它就有可能被分配到堆上。然而,还要对这些变量做逃逸分析,如果函数 return 之后,变量不再被引用,则将其分配到栈上。

其实在 golang 中所有静态内存的其实分配都是在 stack 上进行的,而函数体在执行结束出栈后所有在栈上分配的内存都将得到释放,如果此时直接返回当前作用域变量的指针,这在下层函数的寻址行为就会因为出栈的内存释放而造成空指针异常。这个时候我们就得需要用到 malloc 在堆上(heap)动态分配内存,自己管理内存的生命周期,自己手动释放才是安全的方式。

然而 escape analysis 的存在让 go 完美规避了这些问题,编译器在编译时对代码做了分析,如果发现当前作用域的变量没有超出函数范围,则会自动在 stack 上分配,如果找不到了,则会在 heap 上分配。这样其实开发者就不用太关心堆栈的使用边界,在代码层面上完全不需要关心内存的分配,把底层要考虑的问题交给编译器,同时也减小了 gc 回收的压力。

go 在一定程度消除了堆和栈的区别,因为 go 在编译的时候进行逃逸分析,来决定一个对象放栈上还是放堆上,不逃逸的对象放栈上,可能逃逸的放堆上。

那么逃逸分析的作用是什么呢?

1.逃逸分析的好处是为了减少 gc 的压力,不逃逸的对象分配在栈上,当函数返回时就回收了资源,不需要 gc 标记清除。

2.逃逸分析完后可以确定哪些变量可以分配在栈上,栈的分配比堆快,性能好(逃逸的局部变量会在堆上分配 ,而没有发生逃逸的则有编译器在栈上分配)。

3.同步消除,如果你定义的对象的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行。

我们在开发的时候其实也可以自己去设置和查看逃逸分析的 log,我们可以分析逃逸日志,只要在编译的时候加上-gcflags ‘-m’,但是我们为了不让编译时自动内连函数,一般会加-l 参数,最终为-gcflags ‘-m -l’.在 main 中可以用:

go run -gcflags '-m -l' main.go

那么接着就会有个问题什么时候会逃逸,什么时候不会逃逸呢?

接下来我们看个例子:

package main

type Elegance struct{}

func main() {
	var e Elegance
	p := &e
	_ = *identity(y)
}

func identity(m *Elegance) *Elegance {
	return m
}

运行后:

main.go:11: leaking param: m to result ~r1 level=0
main.go:7: main &e does not escape

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

通常在 go 中函数都是运行在栈上的,在栈声明临时变量分配内存,函数运行完毕在回收该段栈空间,并且每个函数的栈空间都是独立的,不能被访问到的。但是在某些情况下,栈上的空间需要在该函数被释放后依旧能访问到,这时候就涉及到内存的逃逸了

type data struct {
    name string
}

func patent1()data{
    p := data{"keke"}
    return p
}

func patent2() *data {
    p := data{"jame"}
    return &p
}
func main(){
    p1 := patent1()
    p2 := patent2()
}

这里的 patent1 和 patent2 函数都有返回值,唯一不同的地方是 patent1 返回 data 结构体,patent2 返回 data 结构体指针。在大多数语言例如 C 类似 patent2 的函数是不对的,因为 p 是一个临时变量,返回过后就会被释放掉,返回毫无意义。但是在 golang 中,这种语法是允许的,它能正确的把 p 的地址返回到上层调用函数而不被释放。

这样该函数在运行完毕后肯定是要释放的,内部分配的临时内存也要释放,所以 p 也应该被释放。而为了让 p 能被正确返回到上层调用,golang 采取了一种内存策略,把 p 从栈拿到堆的中去,此时 p 就不会跟随 patent2 一同消亡了,这个过程就是逃逸。