1. golang 逃逸分析

1.1. 什么是堆和栈?

1.1.1. 栈

在程序中, 每个函数块都会有自己的内存区域用来存自己的局部变量 (内存占用少)、返回地址、返回值之类的数据, 这一块内存区域有特定的结构和寻址方式, 大小在编译时已经确定, 寻址起来也十分迅速, 开销很少。这一块内存地址称为栈。栈是线程级别的, 大小在创建的时候已经确定, 所以当数据太大的时候, 就会发生 “stack overflow”。

1.1.2. 堆

在程序中, 全局变量、内存占用大的局部变量、发生了逃逸的局部变量存在的地方就是堆, 这一块内存没有特定的结构, 也没有固定的大小, 可以根据需要进行调整。简单来说, 有大量数据要存的时候, 就存在堆里面。堆是进程级别的。当一个变量需要分配在堆上的时候, 开销会比较大, 对于 go 这种带 GC 的语言来说, 也会增加 gc 压力, 同时也容易造成内存碎片。

1.1.3. go

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

1.2. 两种方法判断是否逃逸

1.2.1. 使用编译参数

go build-gcflags '-m -l'
# go tool compile --help

-l    disable inlining    禁止内联优化
-m    print optimization decisions 可以检查代码的编译优化情况, 包括逃逸情况和函数是否内联。
package main

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

func main() {
	a := f1()
	*a++
}
# command-line-arguments
.\main.go:4:2: moved to heap: i

这样可以很直观地看到在第 4 行, i 发生了逃逸, 内存会分配在堆上。

除了使用编译参数之外, 我们还可以使用一种更底层的, 更硬核, 也更准确的方式来判断一个对象是否逃逸, 那就是: 直接看汇编!

1.2.2. 使用汇编

go tool compile -S
$ go tool compile -S escape.go | grep escape.go:10
    0x001d 00029 (escape.go:10) PCDATA  $2, $1
    0x001d 00029 (escape.go:10) PCDATA  $0, $0
    0x001d 00029 (escape.go:10) LEAQ    type.int(SB), AX
    0x0024 00036 (escape.go:10) PCDATA  $2, $0
    0x0024 00036 (escape.go:10) MOVQ    AX, (SP)
    0x0028 00040 (escape.go:10) CALL    runtime.newobject(SB)
    0x002d 00045 (escape.go:10) PCDATA  $2, $1
    0x002d 00045 (escape.go:10) MOVQ    8(SP), AX
    0x0032 00050 (escape.go:10) MOVQ    $1, (AX)
runtime.newobject(SB)

1.3. 逃逸分析的用处 (为了性能)

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

因为逃逸分析完后可以确定哪些变量可以分配在栈上, 栈的分配比堆快, 性能好。

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

1.4. 扩展

go tool link --help
-ldflagsgo tool linkgo tool link --help
go build -gcflags -gcflags='log=-N -l' main.go

只在编译特定包时需要传递参数, 格式应遵守 “包名 = 参数列表”。

1.4.3. 内联场景

此时, 爱思考的读者可能就会产生疑问: 既然内联优化效果这么显著, 是不是所有的函数调用都可以内联呢? 答案是不可以。因为内联, 其实就是将一个函数调用原地展开, 替换成这个函数的实现。当该函数被多次调用, 就会被多次展开, 这会增加编译后二进制文件的大小。而非内联函数, 只需要保存一份函数体的代码, 然后进行调用。所以, 在空间上, 一般来说使用内联函数会导致生成的可执行文件变大 (但需要考虑内联的代码量、调用次数、维护内联关系的开销)。

1.5. 总结

以上提供了两种方法可以用来判断某个变量是否发生了逃逸, 其中使用编译参数比较简单, 使用汇编比较硬核。通过这两种方法分析完逃逸, 就能进一步优化堆上内存数量, 减轻 GC 压力了。

尽量写出分配在栈上的代码, 堆上的变量变少了, 可以减轻内存分配的开销, 减小 gc 的压力, 提高程序的运行速度。