目录

Golang 逃逸分析

堆和栈

要理解什么是逃逸分析会涉及堆和栈的一些基本知识,如果忘记的同学我们可以简单的回顾一下:

  • 堆(Heap):一般来讲是人为手动进行管理,手动申请、分配、释放。堆适合不可预知大小的内存分配,这也意味着为此付出的代价是分配速度较慢,而且会形成内存碎片。
  • 栈(Stack):由编译器进行管理,自动申请、分配、释放。一般不会太大,因此栈的分配和回收速度非常快;我们常见的函数参数(不同平台允许存放的数量不同),局部变量等都会存放在栈上。

栈分配内存只需要两个 CPU 指令:“PUSH"和“RELEASE”,分配和释放;而堆分配内存首先需要去找到一块大小合适的内存块,之后要通过垃圾回收才能释放。

通俗比喻的说, 就如我们去饭馆吃饭,只需要点菜(发出申请)–》吃吃吃(使用内存)–》吃饱就跑剩下的交给饭馆(操作系统自动回收),而 就如在家里做饭,大到家,小到买什么菜,每一个环节都需要自己来实现,但是自由度会大很多。

什么是逃逸分析

在编译程序优化理论中,逃逸分析是一种确定指针动态范围的方法,简单来说就是分析在程序的哪些地方可以访问到该指针。

再往简单的说,Go 是通过在编译器里做逃逸分析(escape analysis)来决定一个对象放栈上还是放堆上,不逃逸的对象放栈上,可能逃逸的放堆上;即我发现 变量 在退出函数后没有用了,那么就把丢到栈上,毕竟栈上的内存分配和回收比堆上快很多;反之,函数内的普通变量经过 逃逸分析 后,发现在函数退出后 变量 还有在其他地方上引用,那就将 变量 分配在堆上。做到按需分配(哪里的人民需要我,我就往哪去~~,一个党员的呐喊)。

为何需要逃逸分析

ok,了解完 各自的优缺点后,我们就可以更好的知道 逃逸分析 存在的目的了:

  1. 减少 gc 压力,栈上的变量,随着函数退出后系统直接回收,不需要 gc 标记后再清除。
  2. 减少内存碎片的产生。
  3. 减轻分配堆内存的开销,提高程序的运行速度。

如何确定是否逃逸

Go 中通过逃逸分析日志来确定变量是否逃逸,开启逃逸分析日志:

1
go run -gcflags '-m -l' main.go
  • -m 会打印出逃逸分析的优化策略,实际上最多总共可以用 4 个 -m ,但是信息量较大,一般用 1 个就可以了。
  • -l 会禁用函数内联,在这里禁用掉 内联 能更好的观察逃逸情况,减少干扰。

逃逸案例

案例一:取地址发生逃逸

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package main

type UserData struct {
    Name  string
}

func main() {
    var info UserData
    info.Name = "WilburXu"
    _ = GetUserInfo(info)
}

func GetUserInfo(userInfo UserData) *UserData {
    return &userInfo
}

执行 go run -gcflags '-m -l' main.go 后返回以下结果:

1
2
3
# command-line-arguments
.\main.go:14:9: &userInfo escapes to heap
.\main.go:13:18: moved to heap: userInfo

GetUserInfo 函数里面的变量 userInfo 逃到堆上了(分配到堆内存空间上了)。

GetUserInfo 函数的返回值为 *UserData 指针类型,然后 将值变量 userInfo 的地址返回,此时编译器会判断该值可能会在函数外使用,就将其分配到了堆上,所以变量 userInfo 就逃逸了。

优化方案

1
2
3
4
5
6
7
8
9
func main() {
    var info UserData
    info.Name = "WilburXu"
    _ = GetUserInfo(&info)
}

func GetUserInfo(userInfo *UserData) *UserData {
    return userInfo
}
1
2
3
# command-line-arguments
.\main.go:13:18: leaking param: userInfo to result ~r1 level=0
.\main.go:10:18: main &info does not escape

对一个变量取地址,可能会被分配到堆上。但是编译器进行逃逸分析后,如果发现到在函数返回后,此变量不会被引用,那么还是会被分配到栈上。套个取址符,就想骗补助?

编译器傲娇的说:Too young,Too Cool…!

案例二 :未确定类型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package main

type User struct {
    name interface{}
}

func main() {
    name := "WilburXu"
    MyPrintln(name)
}

func MyPrintln(one interface{}) (n int, err error) {
    var userInfo = new(User)
    userInfo.name = one // 泛型赋值 逃逸咯
    return
}

执行 go run -gcflags '-m -l' main.go 后返回以下结果:

1
2
3
4
# command-line-arguments
./main.go:12:16: leaking param: one
./main.go:13:20: MyPrintln new(User) does not escape
./main.go:9:11: name escapes to heap

这里可能有同学会好奇, MyPrintln 函数内并没有被引用的便利,为什么变了 name 会被分配到了 上呢?

上一个案例我们知道了,普通的手法想去”骗取补助”,聪明灵利的编译器是不会“上当受骗的噢”;但是对于 interface 类型,很遗憾,go 编译器或者链接器不可能在编译的时候计算两者的对应关系,因此只能分配到 上。

优化方案

将结构体 User 的成员 name 的类型、函数 MyPringLn 参数 one 的类型改为 string ,将得出:

1
2
3
# command-line-arguments
./main.go:12:16: leaking param: one
./main.go:13:20: MyPrintln new(User) does not escape

拓展分析

对于案例二的分析,我们还可以通过反编译命令 go tool compile -S main.go 查看,会发现如果为 interface 类型,main 主函数在编译后会 额外 多出以下指令:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# main.go:9 -> MyPrintln(name)
    0x001d 00029 (main.go:9)    PCDATA  $2, $1
    0x001d 00029 (main.go:9)    PCDATA  $0, $1
    0x001d 00029 (main.go:9)    LEAQ    go.string."WilburXu"(SB), AX
    0x0024 00036 (main.go:9)    PCDATA  $2, $0
    0x0024 00036 (main.go:9)    MOVQ    AX, ""..autotmp_5+32(SP)
    0x0029 00041 (main.go:9)    MOVQ    $8, ""..autotmp_5+40(SP)
    0x0032 00050 (main.go:9)    PCDATA  $2, $1
    0x0032 00050 (main.go:9)    LEAQ    type.string(SB), AX
    0x0039 00057 (main.go:9)    PCDATA  $2, $0
    0x0039 00057 (main.go:9)    MOVQ    AX, (SP)
    0x003d 00061 (main.go:9)    PCDATA  $2, $1
    0x003d 00061 (main.go:9)    LEAQ    ""..autotmp_5+32(SP), AX
    0x0042 00066 (main.go:9)    PCDATA  $2, $0
    0x0042 00066 (main.go:9)    MOVQ    AX, 8(SP)
    0x0047 00071 (main.go:9)    CALL    runtime.convT2Estring(SB)

对于 Go 汇编语法 不熟悉的可以参考 Golang汇编快速指南

案例三:间接赋值(Assignment to indirection escapes)

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package main

type User struct {
    name interface{}
    age *int
}

func main() {
    var (
        userOne User
        userTwo = new(User)
    )
    userOne.name = "WilburXuOne"    // 不逃逸
    userTwo.name = "WilburXuTwo"    // 逃逸

    userOne.age = new(int)  // 不逃逸
    userTwo.age = new(int)  // 逃逸
}

执行 go run -gcflags '-m -l' main.go 后返回以下结果:

1
2
3
4
5
6
# command-line-arguments
.\main.go:14:17: "WilburXuTwo" escapes to heap
.\main.go:17:19: new(int) escapes to heap
.\main.go:11:16: main new(User) does not escape
.\main.go:13:17: main "WilburXuOne" does not escape
.\main.go:16:19: main new(int) does not escape

为什么这里 类型不会逃逸而 引用类型 会逃逸呢?这是因为在 userTwo = new(User) 对象的创建时,编译器先是分析 userTwo 对象可能分配在 上,同时成员变量 nameage 也为 引用类型 ,为了保证不出现 回收后,导致对象 userTwo 的成员值也被回收,所以 nameage 需要逃逸。

但是,如果 nameage 为值类型,那么编译器虽然初步分析 userTwo 会分配在 上,但由于 main 主函数结束后,变量都会被回收,也就是说对象没有被其他引用,那么就都会分配在 上,所以 nameage 没有发生逃逸。

优化建议

尽量不要将 引用对象 赋值给 引用对象

总结

不要盲目使用变量的指针作为函数参数,虽然它会减少复制操作。但其实当参数为变量自身的时候,复制是在栈上完成的操作,开销远比变量逃逸后动态地在堆上分配内存少的多。

Go 的编译器就如一个聪明的孩子一般,大多时候在逃逸分析问题上的处理都令人眼前一亮,但有时闹性子的时候处理也是非常粗糙的分析或完全放弃,毕竟这是孩子天性不是吗? 所以也需要我们在编写代码的时候多多观察,多多留意了。

参考

https://github.com/WilburXu/blog/blob/master/Golang/Go%20%E9%80%83%E9%80%B8%E5%88%86%E6%9E%90.md