指针逃逸

GO语言变量逃逸分析

空格键_11aa · 2019-07-09 21:32:41 · 274 次点击 · 预计阅读时间 1 分钟 · 大约1分钟之前 开始浏览

这是一个创建于 2019-07-09 21:32:41 的文章,其中的信息可能已经有所发展或是发生改变。

引言

​ 内存管理的灵活性是让C/C++程序猿们又爱又恨的东西,比如malloc或new一块内存我可以整个进程使用。但是,如果这块内存在某个函数中new了,但是暂时不能释放那就是悲剧开始了。鬼知道何时释放合适及是不是我还记得我new过它。所以后来很多语言都限制了内存管理或者优化了内存管理机制,添加gc机制来“辅助”程序猿们编程。变量分配在堆上还是栈上不是由是否new/malloc决定,而是通过编译器的“逃逸分析”来决定。

什么是逃逸分析

​ 在编译程序优化理论中,逃逸分析是一种确定指针动态范围的方法——分析在程序的哪些地方可以访问到指针。也是就是说逃逸分析是解决指针作用范围的编译优化方法。编程中常见的两种逃逸情景:

​ 1,函数中局部对象指针被返回(不确定被谁访问)

​ 2,对象指针被多个子程序(如线程 协程)共享使用

为什么要做逃逸分析

​ 开始我们提到go语言中对象内存的分配不是由语言运算符或函数决定,而是通过逃逸分析来决定。为什么要这么干呢?其实说到底还是为了优化程序。函数中生成一个新对象:

1,如果分配到栈上,待函数返回资源就被回收了

2,如果分配到堆上,函数返回后交给gc来管理该对象资源

栈资源的分配及回收速度比堆要快,所以逃逸分析最大的好处应该是减少了GC的压力。

逃逸分析原理

逃逸分析的场景

指针逃逸

典型的逃逸case,函数返回局部变量的指针。

img

运行:go build -gcflags "-m -l" escap01.go

-m 可以用多个来打印更详细的信息,-l去掉inline信息。局部变量a被分配到堆上。

栈空间不足逃逸

当对象大小超过的栈帧大小时(详见go内存分配),变量对象发生逃逸被分配到堆上。

img

当s的容量足够大时,s逃逸到堆上。t容量较小分配到栈上

闭包引用逃逸

img

Fibonacci()函数返回一个函数变量赋值给f,f就成了一个闭包。闭包f保存了a b的地址引用,所以每次调用f()后ab的值发生变化。ab发生逃逸。

img

但如果直接调用Fibonacci(),则ab都是独立的局部变量。

动态类型逃逸

当对象不确定大小或者被作为不确定大小的参数时发生逃逸。

img

t的大小是个变量所以会逃逸到堆上。size作为interface{}参数逃逸到堆上。

切片或map赋值

在给切片或者map赋值对象指针(与对象共享内存地址时),对象会逃逸到堆上。但赋值对象值或者返回对象值切片是不会发生逃逸的。

img

变量逃逸情况还有很多,暂时学习整理这些。程序性能优化是一个很重要的方向,对于现在还在完善的go编译器,我们需要不断总结现有缺陷,尽量在编码时注意潜在的问题,不要把优化都留给编译器(也不可能都留给它,因为我也不知道要优化什么 0-0 )。

逃逸策略

  • 如果编译器不能证明某个变量在函数返回后不再被引用,则分配在堆上
  • 如果一个变量过大,则有可能分配在堆上

分析目的

  • 不逃逸的对象分配在栈上,则变量在用完后就会被编译器回收,从而减少GC的压力
  • 栈上的分配要比堆上的分配更加高效
  • 同步消除,如果定义的对象上有同步锁,但是栈在运行时只有一个线程访问,逃逸分析后如果在栈上则会将同步锁去除

逃逸场景

指针逃逸

在 build 的时候,通过添加 -gcflags "-m" 编译参数就可以查看编译过程中的逃逸分析

在有些时候,因为变量太大等原因,我们会选择返回变量的指针,而非变量,这里其实就是逃逸的一个经典现象

func main() {
    test()
}

func test() *int {
    i := 1
    return &i
}

逃逸分析结果:

# command-line-arguments
./main.go:7:6: can inline test
./main.go:3:6: can inline main
./main.go:4:6: inlining call to test
./main.go:4:6: main &i does not escape
./main.go:9:9: &i escapes to heap
./main.go:8:2: moved to heap: i

可以看到最后两行指出,变量 i 逃逸到了 heap

栈空间不足逃逸

首先,我们尝试创建一个 长度较小的 slice

func main() {
    stack()
}

func stack() {
    s := make([]int, 10, 10)
    s[0] = 1
}

逃逸分析结果:

./main.go:12:6: can inline stack
./main.go:3:6: can inline main
./main.go:4:7: inlining call to stack
./main.go:4:7: main make([]int, 10, 10) does not escape
./main.go:13:11: stack make([]int, 10, 10) does not escape

结果显示未逃逸

然后,我们创建一个超大的slice

func main() {
    stack()
}

func stack() {
    s := make([]int, 100000, 100000)
    s[0] = 1
}

逃逸分析结果:

./main.go:12:6: can inline stack
./main.go:3:6: can inline main
./main.go:4:7: inlining call to stack
./main.go:4:7: make([]int, 100000, 100000) escapes to heap
./main.go:13:11: make([]int, 100000, 100000) escapes to heap

这时候就逃逸到了堆上了

动态类型逃逸

func main() {
    dynamic()
}

func dynamic() interface{} {
    i := 0
    return i
}

逃逸分析结果:

./main.go:18:6: can inline dynamic
./main.go:3:6: can inline main
./main.go:5:9: inlining call to dynamic
./main.go:5:9: main i does not escape
./main.go:20:2: i escapes to heap

这里的动态类型逃逸,其实在理解了interface{}的内部结构后,还是可以归并到 指针逃逸 这一类的,有兴趣的同学可以看一下 《深入理解Go的interface》

闭包引用逃逸

func main() {
    f := fibonacci()
    for i := 0; i < 10; i++ {
        f()
    }
}
func fibonacci() func() int {
    a, b := 0, 1
    return func() int {
        a, b = b, a+b
        return a
    }
}

逃逸分析结果:

./main.go:11:9: can inline fibonacci.func1
./main.go:11:9: func literal escapes to heap
./main.go:11:9: func literal escapes to heap
./main.go:12:10: &b escapes to heap
./main.go:10:5: moved to heap: b
./main.go:12:13: &a escapes to heap
./main.go:10:2: moved to heap: a

逃逸的其他情况

1.被已经逃逸的变量引用的指针,肯定会引发逃逸

type A struct{
    data *int
}

func newA() *A{
    return &A{}
}
func main(){
    a := newA() //返回值是A结构体指针
    b := 2
    a.data = &b //此时a的指针必然发生逃逸
}

下图可以看到A是一个已经逃逸的指针,引用了b的指针,所以b的指针也发生了逃逸

image-20191216161147822

我们再看上面备注中的代码例子:

func main() {
    a := make([]*int,1)
    b := 12
    a[0] = &b
}

结果:

➜  testProj go run -gcflags "-m -l" main.go
# command-line-arguments
./main.go:7:2: moved to heap: b
./main.go:6:11: main make([]*int, 1) does not escape

sliace a并没有发生逃逸,但是被a引用的b依然逃逸了。类似的情况同样发生在map和chan中:

func main() {
    a := make([]*int,1)
    b := 12
    a[0] = &b

    c := make(map[string]*int)
    d := 14
    c["aaa"]=&d

    e := make(chan *int,1)
    f := 15
    e <- &f
}

结果:

➜  testProj go run -gcflags "-m -l" main.go
# command-line-arguments
./main.go:7:2: moved to heap: b
./main.go:11:2: moved to heap: d
./main.go:15:2: moved to heap: f
./main.go:6:11: main make([]*int, 1) does not escape
./main.go:10:11: main make(map[string]*int) does not escape

被chan,map,slice引用的指针必然会发生逃逸

image-20191216161942763

由此我们可以得出结论:

被指针类型的slice、map和chan引用的指针一定发生逃逸
备注:stack overflow上有人提问为什么使用指针的chan比使用值的chan慢30%,答案就在这里:使用指针的chan发生逃逸,gc拖慢了速度。问题链接https://stackoverflow.com/questions/41178729/why-passing-pointers-to-channel-is-slower

总结

我们得出了指针必然发生逃逸的三种情况(go version go1.13.4 darwin/amd64):

  • 在某个函数中new或字面量创建出的变量,将其指针作为函数返回值,则该变量一定发生逃逸(构造函数返回的指针变量一定逃逸);
  • 被已经逃逸的变量引用的指针,一定发生逃逸;
  • 被指针类型的slice、map和chan引用的指针,一定发生逃逸;

同时我们也得出一些必然不会逃逸的情况:

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

**有一些情况****可能发生逃逸,也可能不会发生逃逸:

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




posted @ 2019-12-19 19:53  离地最远的星  阅读(1035)  评论(0编辑  收藏  举报