Go 语言内存管理(三):逃逸分析

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Go 语言内存管理(三):逃逸分析相关的知识,希望对你有一定的参考价值。

参考技术A

Go 语言较之 C 语言一个很大的优势就是自带 GC 功能,可 GC 并不是没有代价的。写 C 语言的时候,在一个函数内声明的变量,在函数退出后会自动释放掉,因为这些变量分配在栈上。如果你期望变量的数据可以在函数退出后仍然能被访问,就需要调用 malloc 方法在堆上申请内存,如果程序不再需要这块内存了,再调用 free 方法释放掉。Go 语言不需要你主动调用 malloc 来分配堆空间,编译器会自动分析,找出需要 malloc 的变量,使用堆内存。编译器的这个分析过程就叫做逃逸分析。

所以你在一个函数中通过 dict := make(map[string]int) 创建一个 map 变量,其背后的数据是放在栈空间上还是堆空间上,是不一定的。这要看编译器分析的结果。

可逃逸分析并不是百分百准确的,它有缺陷。有的时候你会发现有些变量其实在栈空间上分配完全没问题的,但编译后程序还是把这些数据放在了堆上。如果你了解 Go 语言编译器逃逸分析的机制,在写代码的时候就可以有意识地绕开这些缺陷,使你的程序更高效。

Go 语言虽然在内存管理方面降低了编程门槛,即使你不了解堆栈也能正常开发,但如果你要在性能上较真的话,还是要掌握这些基础知识。

这里不对堆内存和栈内存的区别做太多阐述。简单来说就是, 栈分配廉价,堆分配昂贵。 栈空间会随着一个函数的结束自动释放,堆空间需要时间 GC 模块不断地跟踪扫描回收。如果对这两个概念有些迷糊,建议阅读下面 2 个文章:

这里举一个小例子,来对比下堆栈的差别:

stack 函数中的变量 i 在函数退出会自动释放;而 heap 函数返回的是对变量 i 的引用,也就是说 heap() 退出后,表示变量 i 还要能被访问,它会自动被分配到堆空间上。

他们编译出来的代码如下:

逻辑的复杂度不言而喻,从上面的汇编中可看到, heap() 函数调用了 runtime.newobject() 方法,它会调用 mallocgc 方法从 mcache 上申请内存,申请的内部逻辑前面文章已经讲述过。堆内存分配不仅分配上逻辑比栈空间分配复杂,它最致命的是会带来很大的管理成本,Go 语言要消耗很多的计算资源对其进行标记回收(也就是 GC 成本)。

Go 编辑器会自动帮我们找出需要进行动态分配的变量,它是在编译时追踪一个变量的生命周期,如果能确认一个数据只在函数空间内访问,不会被外部使用,则使用栈空间,否则就要使用堆空间。

我们在 go build 编译代码时,可使用 -gcflags \'-m\' 参数来查看逃逸分析日志。

以上面的两个函数为例,编译的日志输出是:

日志中的 &i escapes to heap 表示该变量数据逃逸到了堆上。

需要使用堆空间,所以逃逸,这没什么可争议的。但编译器有时会将 不需要 使用堆空间的变量,也逃逸掉。这里是容易出现性能问题的大坑。网上有很多相关文章,列举了一些导致逃逸情况,其实总结起来就一句话:

多级间接赋值容易导致逃逸

这里的多级间接指的是,对某个引用类对象中的引用类成员进行赋值。Go 语言中的引用类数据类型有 func , interface , slice , map , chan , *Type(指针) 。

记住公式 Data.Field = Value ,如果 Data , Field 都是引用类的数据类型,则会导致 Value 逃逸。这里的等号 = 不单单只赋值,也表示参数传递。

根据公式,我们假设一个变量 data 是以下几种类型,相应的可以得出结论:

下面给出一些实际的例子:

如果变量值是一个函数,函数的参数又是引用类型,则传递给它的参数都会逃逸。

上例中 te 的类型是 func(*int) ,属于引用类型,参数 *int 也是引用类型,则调用 te(&j) 形成了为 te 的参数(成员) *int 赋值的现象,即 te.i = &j 会导致逃逸。代码中其他几种调用都没有形成 多级间接赋值 情况。
同理,如果函数的参数类型是 slice , map 或 interface 都会导致参数逃逸。

匿名函数的调用也是一样的,它本质上也是一个函数变量。有兴趣的可以自己测试一下。

只要使用了 Interface 类型(不是 interafce ),那么赋值给它的变量一定会逃逸。因为 interfaceVariable.Method() 先是间接的定位到它的实际值,再调用实际值的同名方法,执行时实际值作为参数传递给方法。相当于 interfaceVariable.Method.this = realValue

向 channel 中发送数据,本质上就是为 channel 内部的成员赋值,就像给一个 slice 中的某一项赋值一样。所以 chan *Type , chan map[Type]Type , chan []Type , chan interface 类型都会导致发送到 channel 中的数据逃逸。

这本来也是情理之中的,发送给 channel 的数据是要与其他函数分享的,为了保证发送过去的指针依然可用,只能使用堆分配。

可变参数如 func(arg ...string) 实际与 func(arg []string) 是一样的,会增加一层访问路径。这也是 fmt.Sprintf 总是会使参数逃逸的原因。

例子非常多,这里不能一一列举,我们只需要记住分析方法就好,即,2 级或更多级的访问赋值会 容易 导致数据逃逸。这里加上 容易 二字是因为随着语言的发展,相信这些问题会被慢慢解决,但现阶段,这个可以作为我们分析逃逸现象的依据。

下面代码中包含 2 种很常规的写法,但他们却有着很大的性能差距,建议自己想下为什么。

Benchmark 和 pprof 给出的结果:

熟悉堆栈概念可以让我们更容易看透 Go 程序的性能问题,并进行优化。

多级间接赋值会导致 Go 编译器出现不必要的逃逸,在一些情况下可能我们只需要修改一下数据结构就会使性能有大幅提升。这也是很多人不推荐在 Go 中使用指针的原因,因为它会增加一级访问路径,而 map , slice , interface 等类型是不可避免要用到的,为了减少不必要的逃逸,只能拿指针开刀了。

大多数情况下,性能优化都会为程序带来一定的复杂度。建议实际项目中还是怎么方便怎么写,功能完成后通过性能分析找到瓶颈所在,再对局部进行优化。

聊聊go语言逃逸分析

1. 从一个例子开始

下面是一段c代码,函数getStr生成了a-z的串,我们分别在函数内部和main中对字串进行了输出。

//例1.1
#include <stdio.h>

//返回字串
char* getStr()
    //char数组 函数栈上分配内存
    char buf[27];
    int i;
    
    //产生a-z的串
    for (i=0; i<sizeof(buf)-1; i++)
        buf[i] = i + 'a';
    
    buf[i] = '\\0';
    
    printf("%s\\n", buf);
    return buf;


int main()
    char *p;
    p = getStr();
    printf("%s\\n", *p);

    return 0;

运行结果如下:

abcdefghijklmnopqrstuvwxyz
m

如果你有一些c的编程经验,那么你一定知道产生这个结果是因为buf[27]的内存是在函数栈上分配的,这段内存在函数结束后会被自动回收,所以在main函数中想再次输出这个字串,就会产生一个未知的结果。
我们在对上面代码进行编译时,编译器也会给出 告警:

In function ‘getStr’:
warning: function returns address of local variable [-Wreturn-local-addr]

解决这个问题的方法之一(只是一种方法,并非好的实践)是在函数内部使用malloc申请一段内存,因为
malloc的内存是在堆上分配的,函数返回后不会自动回收因此可以得到预期结果。代码如下:

//例1.2
#include <stdio.h>
#include <stdlib.h>

char* getStr()
    char *buf;
    int len = 27; 
    //堆上分配内存,不会在函数结束后被自动回收
    buf = (char *) malloc(len);

    int i;
    for (i=0; i<len-1; i++)
        buf[i] = i + 'a';
       

    buf[i] = '\\0';

    printf("%s\\n", buf);
    return buf;


int main()
    char *p; 
    p = getStr();
    printf("%s\\n", p); 
    
    //手动将堆上内存释放
    free(p);
    return 0;

类似的功能,我们用go语言实现,可以是这样的:

//例1.3
package main

import "fmt"

func getStr() *[26] byte
    buf := [26]byte
    for i:=0; i<len(buf); i++
        buf[i] = byte('a' + i)
    

    return &buf


func main()
    var p *[26] byte
    p = getStr();

    fmt.Printf("%s\\n", *p)

运行结果如下:

abcdefghijklmnopqrstuvwxyz

这段程序中,我们并没有在getStr中指出buf是要分配在堆上的,但是程序为什么能正确运行呢?正是因为go中有逃逸分析机制。

2. 什么是逃逸分析

函数中的一个变量,其内存是分配在堆上,还是分配在栈上?在go语言中,这一点是由编译器决定的,这就是所谓的逃逸分析。例1.3中,go编译器发现buf对应的内存在函数返回后仍然被使用,于是自动将其分配到堆上,从而保证了程序可以正常运行。而且逃逸至堆上的内存,其回收也是由go的垃圾回收机制自动完成,yyds!

3. 查看逃逸的方法

假如我们的代码是escape.go,可以使用如下命令查看实际的逃逸情况。

//逃逸概要情况
go build -gcflags "-m" escape.go
//详情
go build -gcflags "-m -m" escape.go

对于例1.3中的代码,执行go build -gcflags "-m",得到结果如下:

# command-line-arguments
./c.go:20:15: inlining call to fmt.Printf
./c.go:6:5: moved to heap: buf
./c.go:20:24: *p escapes to heap
./c.go:20:15: []interface  literal does not escape
<autogenerated>:1: .this does not escape

可见buf的确逃逸到了堆上。

4. 产生逃逸的情况

哪些情况go会将函数栈上的内存分配至堆上呢?官方的FAQ(How do I know whether a variable is allocated on the heap or the stack?)里给出了答案

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.

可见逃逸的情形主要有两大类:

  • 编译器无法证明变量在函数返回后不再被引用,则分配在堆上。
  • 如果变量比较大,则放在堆上更合理。

4.1 函数返回后变量仍被使用的情况

  1. 由于闭包,导致函数返回后函数内变量仍被外部使用。
package main

func main() 
    f := fibonacci()
    f()


func fibonacci() func() int 
    a, b := 0, 1
    return func() int 
        a, b = b, a+b
        return a
    

查看逃逸情况如下:

go build -gcflags "-m -l" escape.go 
# command-line-arguments
./escape.go:9:5: moved to heap: a
./escape.go:9:8: moved to heap: b
./escape.go:10:12: func literal escapes to heap
  1. 返回指针
package main

type User struct 
    name string
    age int8


//返回指向User的指针
func NewUser() *User
    u := User
        name: "ball",
        age: 99,
    
    
    //u对应的内存可能在外部被使用,放到堆上
    return &u


func main() 

查看逃逸情况如下:

go build -gcflags "-m  -l" escape.go 
# command-line-arguments
./escape.go:9:2: moved to heap: u
  1. 返回接口
package main

type Man interface
    Show()


type User struct 
    name string
    age int8


func (u User)Show()


func NewMan() (m Man)
    u:= User
        name: "ball",
        age: 99, 
       
    
    m = u 
    
    return


func main() 

查看逃逸情况如下:

go build -gcflags "-m -l" escape.go    
# command-line-arguments
./escape.go:12:7: u does not escape
./escape.go:21:4: u escapes to heap
<autogenerated>:1: .this does not escape
<autogenerated>:1: leaking param: .this

Newman中有一个u到接口m的转换。go同的接口由动态值和动态类型两部分构成,m中的动态值指针,指向了u(更准备的说应该是u的拷贝)对应的内存,这部分是可能在函数返回后会用到的,所以只能分配在堆上。

4.2 变量过大被分配在堆上的情况

//escape.go
package main
func Slice()
    s := make([]int64, 8192, 8192)
    s[0] = 1


func main() 
    Slice()

查看逃逸情况如下:

go build -gcflags "-m -m -l" escape.go 
# command-line-arguments
./escape.go:3:11: make([]int64, 8192, 8192) escapes to heap:
./escape.go:3:11:   flow: heap = &storage for make([]int64, 8192, 8192):
./escape.go:3:11:     from make([]int64, 8192, 8192) (too large for stack) at ./escape.go:3:11
./escape.go:3:11: make([]int64, 8192, 8192) escapes to heap

这里由于切片长度过大(too large for stack),被分配到了栈上。
如果你的好奇心比较强,可能会有如下疑问:

  • go函数栈这么小么,长度是10000的int64切片都放不下?
  • 这个too large到底是多大?

关于这些问题,准备后面写一篇函数栈内存分配的文章专门说明。这里你只要记住结论就可以。

  • go的函数栈是非常大的32位系统250M,64位系统1G。(1.14.4是这样的,不确认不同版本的go是否完全相同)
  • 这里所说的too large不是函数栈的内存不够,而是说一个变量如果对应这么大块的内存,把它分配在栈上的效率大概率比较低,所以放堆上更合理。
  • go1.14.4版本中,too large是指 >= 8字节 * 8192。也就是说如果代码做如下改动,不会产生逃逸。
s := make([]int64, 8191, 8191)

5. 逃逸分析可能带来的问题

5.1 go中内存分配在堆与栈上的不同

  • 如果分配在栈中,则函数执行结束可自动将内存回收;
  • 如果分配在堆中,则函数执行结束交给GC(垃圾回收)处理;

5.2 可能的问题

由5.1可知,如果过多的产生逃逸,会使更多的内存分配在堆上,其后果是GC的压力比较大,这同样可能影响代码运行的效率。实际项目中需要进行权衡,以决定是否要避免逃逸。

我们看下面一个比较极端的例子:

benchmark.go  
package gotest                                                 
                                                               
func Slice(n int64)                                           
    s := make([]int64, 8191, 8191)                             
    s[0] = 1                                                         
 

对应的压测文件

//benchmark_test.go  
package gotest_test

import (
    "testing"
    "gotest"
)

func BenchmarkSlice(b *testing.B)
    for i :=0; i<b.N; i++
        gotest.Slice()
       

Slice中我们设置切片容量8191,此时内存分配在栈上,未发生逃逸。

压测结果

go test -bench=.

goos: linux
goarch: amd64
pkg: gotest
BenchmarkSlice-4        1000000000               0.510 ns/op
PASS
ok      gotest  0.570s

接下来,我们将切片大小改为8192,刚好产生逃逸,内存分配在堆上

s := make([]int64, 8192, 8192)           
go test -bench=. 
goos: linux
goarch: amd64
pkg: gotest
BenchmarkSlice-4           80602             13799 ns/op
PASS
ok      gotest  1.275s

可见,本例中,栈上分配内存带了来巨大的优势。

6. 更多逃逸的情况

第4节中所概括的逃逸情况只是主要场景,还有很多逃逸的情形。

6.1 变量大小不定带来的逃逸

package main

func s()
    n := 10
    s := make([]int32, n)
    s[0] = 1


func main() 

查看逃逸情况如下

go build -gcflags "-m -m -l" escape.go 
# command-line-arguments
./escape.go:5:11: make([]int32, n) escapes to heap:
./escape.go:5:11:   flow: heap = &storage for make([]int32, n):
./escape.go:5:11:     from make([]int32, n) (non-constant size) at ./escape.go:5:11
./escape.go:5:11: make([]int32, n) escapes to heap

编译器给出解释为non-constant size。这也可以理解,大小不定就有可能很大,为了确保栈内存分配的高效,防御性的把它分配到堆上,说得过去。

6.2 那些神奇的逃逸

package main

type X struct 
    p *int


func main() 
    var i1 int
    x1 := &X
        p: &i1, // GOOD: i1 does not escape
    
    *x1.p++

    var i2 int
    x2 := &X
    x2.p = &i2 // BAD: Cause of i2 escape
    *x2.p++

对x1的x2两个的赋值,同样的功能,只因为写法的不同,就造成其中一个产生了逃逸!我能说什么呢…

go build -gcflags "-m -l" escape.go    
# command-line-arguments
./escape.go:15:6: moved to heap: i2
./escape.go:9:8: &X literal does not escape
./escape.go:16:8: &X literal does not escape

对两种方法使用benchmark测试,性能相差近50倍!所以,大家应该知道struct中有指针成员该怎么进行赋值效率最高了吧。

更多匪夷所思的逃逸情况请猛击这里:Escape-Analysis Flaws。不想啃英文的同学点这里Go 逃逸分析的缺陷

参考

以上是关于Go 语言内存管理(三):逃逸分析的主要内容,如果未能解决你的问题,请参考以下文章

Go内存分配和逃逸分析三色标记法

Go 逃逸分析

聊聊go语言逃逸分析

go语言学习笔记 — 基础 — 基本语法 — 常量与变量 — 变量的生命周期:变量逃逸分析 —— go编译器自动决定变量的内存分配方式(堆还是栈),提高程序运行效率

聊聊go语言逃逸分析

聊聊go语言逃逸分析