Go语言内存逃逸分析(非常详细)
栈和堆都是操作系统的虚拟内存空间。Go 语言中的栈和堆有以下特点:
因此,从内存分配和回收管理的角度来看,使用栈的成本要远低于使用堆。而我们常说的内存逃逸分析是编译器在编译阶段进行的一种优化分析。
使用栈指针与活动帧指针可以将当前帧活动的范围标识出来。对于回到栈上方的 G 来说,位于它下方的那块内存总是可以清理的。这就有问题了,因为假设下面一帧返回的是一个地址,而这个地址其实是有用的,但是 G 清理了当前帧下方的内存,那么就有可能会损坏相应的数据。
简单粗暴地将活动帧下方的内存直接置为失效,这种处理方式显然不够完善。所以,Go 编译器又帮我们做了另一件事,那就是进行内存逃逸分析。
进行了内存逃逸分析后,只有在堆上进行垃圾回收时,才会去清理这部分被分配到堆上的数据。
内存逃逸分析会观察变量是怎样被分享的。当需要把值分享给调用栈上方的帧时,内存逃逸分析不会再让这个变量的值分配在栈上,而是会直接分配到堆上。
通常来说,我们总是优先考虑值语义,也就是优先使用数值而不是使用指针,这样可以让值留在栈中,减少改动所波及的范围,避免产生过多的副作用。
在栈上分配内存的成本远小于堆,因为一旦分配到堆上,回收内存时就得让垃圾回收操作介入了。
通常有以下两种情况会引起内存逃逸分析。
如果编译时无法确定变量的作用域,无法知道它是否在其他地方(非局部)被引用,那么只要存在这种被引用的可能性,编译器就必须将该变量分配到堆上。具体包括以下场景:
注意,前面提到的几个需要使用指针的场景都是是否进行内存逃逸分析的重要参考依据。传递指针可以减少底层值的备份,通常可以提高效率。但是,如果要备份的是少量数据,那么使用指针的效率不一定会高于值备份。因为内存逃逸分析会将指针所指向的地址分配到堆上,而回收分配在堆上的内存时需要借助垃圾回收操作。
内存逃逸分析发生在编译阶段,而不是运行阶段。在 Go语言中,可以通过编译器命令看到详细的内存逃逸分析过程。相关命令为:
使用 go build 命令时,如果加上 -gcflags 选项,我们得到的不仅仅是可执行文件,还有一个内存逃逸分析报告。
编写代码时不需要经常去看这种内存逃逸分析报告,在做 profiling 时才需要去看。做 profiling 时会显示有 CPU 或者内存开销的代码。若不知道这些代码为什么会造成巨大开销,就可以结合内存逃逸分析报告一起来分析。通常来说,巨大的内存开销都与堆分配有关。
内存逃逸分析对给程序进行性能分析非常有用。有时,一些代码可能会隐藏着大量的堆操作,调用这种代码时,将会频繁地进行内存分配,这会导致程序 CPU 资源占用率高、垃圾回收耗时长等问题。此时,阅读内存逃逸分析报告,结合分析追踪工具 go pprof 即可快速定位问题。
示例代码如下:
1) 编译时,加上'-l',示例代码如下:
2) 在函数上方添加 //go:noinline 注释,用来告诉编译器不对其进行函数内联优化。例如,在前面的示例代码中,函数 add1() 添加了此提示,这样一来,即使 Go 编译器可以优化函数调用,也不会执行。
函数内联优化是手动优化或编译器优化,它可以减少函数调用本身的开销,使编译器能更高效地执行其他优化策略。
在 Go Runtime 代码中,有一个名为 noescape() 的函数:
使用 noescape() 函数控制内存逃逸分析的代码如下:
- 栈用于存储确定类型和大小的对象,一般不会太大。常见的函数参数、局部变量等都保存在栈上。栈常与SP(Stack Pointer)寄存器一起工作。栈的另一个特点是当前栈帧下面的内存可以立即回收。
- 堆通常用于保存较大的对象。堆分配涉及的指令较多。只有在进行垃圾回收时,Go 的 runtime 才会对堆中的内存进行回收管理。
因此,从内存分配和回收管理的角度来看,使用栈的成本要远低于使用堆。而我们常说的内存逃逸分析是编译器在编译阶段进行的一种优化分析。
内存逃逸分析的由来
栈总是向下增长的,调用函数时,程序是向栈的底部推进的。程序从函数处返回时,会回到栈的上方。使用栈指针与活动帧指针可以将当前帧活动的范围标识出来。对于回到栈上方的 G 来说,位于它下方的那块内存总是可以清理的。这就有问题了,因为假设下面一帧返回的是一个地址,而这个地址其实是有用的,但是 G 清理了当前帧下方的内存,那么就有可能会损坏相应的数据。
简单粗暴地将活动帧下方的内存直接置为失效,这种处理方式显然不够完善。所以,Go 编译器又帮我们做了另一件事,那就是进行内存逃逸分析。
内存逃逸分析的作用
内存逃逸分析是指 Go 编译器通过分析栈函数找到其中有用的数据,并将其分配到堆上以防止被破坏,这样在进行栈管理时就可以避免自动清理这个数据了。进行了内存逃逸分析后,只有在堆上进行垃圾回收时,才会去清理这部分被分配到堆上的数据。
内存逃逸分析会观察变量是怎样被分享的。当需要把值分享给调用栈上方的帧时,内存逃逸分析不会再让这个变量的值分配在栈上,而是会直接分配到堆上。
通常来说,我们总是优先考虑值语义,也就是优先使用数值而不是使用指针,这样可以让值留在栈中,减少改动所波及的范围,避免产生过多的副作用。
在栈上分配内存的成本远小于堆,因为一旦分配到堆上,回收内存时就得让垃圾回收操作介入了。
引起内存逃逸分析的两种情况
设计内存逃逸分析是因为 Go 语言的作者不希望开发者过多地关注内存分配,他希望用编译时的代码分析自动代替人工介入。通常有以下两种情况会引起内存逃逸分析。
1) 无法在编译期确定变量的作用域
在编译过程中,Go 编译器会进行内存逃逸分析,以决定变量的分配位置。如果编译时无法确定变量的作用域,无法知道它是否在其他地方(非局部)被引用,那么只要存在这种被引用的可能性,编译器就必须将该变量分配到堆上。具体包括以下场景:
- 与指针有关的场景。例如,向通道中发送指向数据的指针或者包含指针的值,以及在切片或者映射中存储指针或者包含指针的值时,会将变量分配到堆上;
- 函数中使用了指针、切片或映射,并将其作为参数返回。例如,使用了“return &变量”这种形式,且“&变量”会被共享给调用栈上方的帧,那么内存逃逸分析认为有必要将其分配到堆上;
- 使用了接口。如果编译器在编译时无法确定接口包裹的具体类型,那么也会引起内存逃逸分析,从而导致对象被分配到堆上;
- 闭包。如果在闭包中引用了包外的值,闭包执行的生命周期可能会超过函数的生命周期,那么也会引起内存逃逸分析。
2) 无法确定变量在编译时使用的内存大小
即使没有被外部引用,只要对象过大,无法存放在栈区上,依然有可能引起内存逃逸分析。具体有以下场景:- 编译期无法确定切片的大小。
- 切片或者数组过大,超出了栈大小的限制。
- 扩容(append)需要重新分配内存。
注意,前面提到的几个需要使用指针的场景都是是否进行内存逃逸分析的重要参考依据。传递指针可以减少底层值的备份,通常可以提高效率。但是,如果要备份的是少量数据,那么使用指针的效率不一定会高于值备份。因为内存逃逸分析会将指针所指向的地址分配到堆上,而回收分配在堆上的内存时需要借助垃圾回收操作。
内存逃逸分析发生在编译阶段,而不是运行阶段。在 Go语言中,可以通过编译器命令看到详细的内存逃逸分析过程。相关命令为:
go build -gcflags '-m -l' xxx.go
- -gcflags 用于将标识参数传递给 Go 编译器。
- -m 打印输出内存逃逸分析的优化策略。
- -l 禁用函数内联(禁用函数内联可以减少干扰,从而更好地观察逃逸情况)。
使用 go build 命令时,如果加上 -gcflags 选项,我们得到的不仅仅是可执行文件,还有一个内存逃逸分析报告。
编写代码时不需要经常去看这种内存逃逸分析报告,在做 profiling 时才需要去看。做 profiling 时会显示有 CPU 或者内存开销的代码。若不知道这些代码为什么会造成巨大开销,就可以结合内存逃逸分析报告一起来分析。通常来说,巨大的内存开销都与堆分配有关。
内存逃逸分析示例
接下来,我们展示几个存在内存逃逸分析的示例。package myeccape import ( "fmt" ) func noEscape() { arr := []int{0} arr[1] = 2 } //引用会带来内存逃逸分析 func escapeByRef() []int { arr := []int{0} arr[1] = 2 return arr } //接口类型会带来内存逃逸分析 func escapeByInterface() { fmt.Println([]int{0}) } //闭包会带来内存逃逸分析 func fClosure() func() int { a := 0 return func() int { return a } } type example struct{} //返回的是值,不会进行内存逃逸分析 func noEscapeByValue() example { eg := example{} return eg } //返回的是指针,会进行内存逃逸分析 func escapeByRef() *example { eg := example{} return &eg } //创建大的对象,会进行内存逃逸分析 func escapeByMakeBigSlice() { _ = make([]int, 0, 8192) _ = make([]int, 0, 8193) }使用以下命令检查是否存在内存逃逸分析:
$ go build -gcflags '-m -l' myeccape.go
# command-line-arguments
./myeccape.go:8:14: []int{...} does not escape
./myeccape.go:14:14: []int{...} escapes to heap
./myeccape.go:21:13: ... argument does not escape
./myeccape.go:21:19: []int{...} escapes to heap
./myeccape.go:21:19: []int{...} escapes to heap
./myeccape.go:27:9: func literal escapes to heap
./myeccape.go:42:2: moved to heap: eg
./myeccape.go:48:10: make([]int, 0, 8192) does not escape
./myeccape.go:49:10: make([]int, 0, 8193) escapes to heap
- noEscape() 函数没有返回值,不存在引用,也没有使用接口,所以不存在内存逃逸分析,这从代码 ./myeccape.go:8:14: []int{...} does not escape 中也可以看出。
- escapeByRef() 函数有返回值,存在被调用的可能性,所以存在内存逃逸分析。内存逃逸分析的结果为./myeccape.go:14:14: []int{...} escapes to heap。
- escapeByInterface() 函数虽然没有返回值,但是调用了函数 fmt.Println,而该函数的签名为 func Println(a ...interface{}) (n int, err error),在使用接口时,编译器在编译阶段无法确定其参数的具体类型,所以存在内存逃逸分析。内存逃逸分析的结果为 ./myeccape.go:21:19: []int{...} escapes to heap。
- fClosure() 函数是一个闭包,所以存在内存逃逸分析。内存逃逸分析的结果为./myeccape. go:27:9: func literal escapes to heap。
- noEscapeByValue() 函数返回的是值,escapeByRef() 函数返回的是指针。因为 escapeByRef() 函数访问的数据位于当前帧之外,所以只能通过指针来访问。虽然看起来它与 noEscapeByValue() 函数的操作一样,但编译器却很聪明地知道,这个值已经超出了栈的范围,所以存在内存逃逸分析,内存逃逸分析的结果为./myeccape.go:42:2: moved to heap: eg。
- escapeByMakeBigSlice() 函数用于创建对象。如果切片或者数组过大,超出了栈大小的限制,那么就存在内存逃逸分析,所以内存逃逸分析的结果为./myeccape.go:49:10: make([]int, 0, 8193) escapes to heap。
内存逃逸分析对给程序进行性能分析非常有用。有时,一些代码可能会隐藏着大量的堆操作,调用这种代码时,将会频繁地进行内存分配,这会导致程序 CPU 资源占用率高、垃圾回收耗时长等问题。此时,阅读内存逃逸分析报告,结合分析追踪工具 go pprof 即可快速定位问题。
Go语言函数内联
前面有提到,使用选项“-gcflags= -l”会禁用函数内联。那函数内联又是什么呢?它是一种编译器优化,指的是编译器会将函数代码直接插入调用处,而非常规的跳转执行。在编译阶段,可以使用 go run/build -gcflags -m xxx.go 命令检查输出的内容中是否存在函数内联。示例代码如下:
//go:noinline func add1(a int, b int) int { return a + b } func add2(a int, b int) int { return a + b } func main() { add1(10000000, 20000000) add2(10000000, 20000000) }禁止函数内联有两种方式:
1) 编译时,加上'-l',示例代码如下:
1. -gcflags '-m' //这里的'-m'选项表示开启内存逃逸分析 $ go run -gcflags '-m' inline.go # command-line-arguments ./inline.go:8:6: can inline add2 ./inline.go:12:6: can inline main ./inline.go:14:6: inlining call to add2 2. -gcflags '-m -l' //这里的'-m -1'选项表示既开启内存逃逸分析,又禁用函数内联 $ go run -gcflags '-m -l' inline.go如果只使用 -gcflags '-m',编译器会进行函数内联优化,因此输出的结果中会有 can inline ... 和 inlining call to ... 之类的信息。加上了 '-l' 则表示禁用了函数内联,因此没有函数内联优化的输出信息。
2) 在函数上方添加 //go:noinline 注释,用来告诉编译器不对其进行函数内联优化。例如,在前面的示例代码中,函数 add1() 添加了此提示,这样一来,即使 Go 编译器可以优化函数调用,也不会执行。
函数内联优化是手动优化或编译器优化,它可以减少函数调用本身的开销,使编译器能更高效地执行其他优化策略。
手动控制内存逃逸分析
有时,内存逃逸分析会判断某个内存对象应该分配到堆上,但开发者却不这样认为。那有什么方法可以干扰内存逃逸分析呢?在 Go Runtime 代码中,有一个名为 noescape() 的函数:
// $GOROOT/src/runtime/stubs.go func noescape(p unsafe.Pointer) unsafe.Pointer { x := uintptr(p) return unsafe.Pointer(x ^ 0) // 任何数值与0的异或都是原数 }它常用于 Go 标准库和 runtime 的实现中。noescape() 函数的实现逻辑是让编译器不认为 p 会通过 x 逃逸。因为 uintptr 产生的引用是编译器无法理解的,noescape() 函数通过 uintptr 进行了转换,即将指针转换为了数值,这“切断”了内存逃逸分析的数据流跟踪,以避免对传入的指针进行内存逃逸分析。
使用 noescape() 函数控制内存逃逸分析的代码如下:
type Example struct { i, j, k int s []string } func NoEscapeByInterface() interface{} { return (*int)(noescape(unsafe.Pointer(new(Example)))) }再次使用命令检查是否存在内存逃逸分析,会得到如下结果:
$ go build -gcflags '-m -l' noescape.go # command-line-arguments ./noescape.go:8:15: p does not escape ./noescape.go:19:43: new(Example) does not escape其中./noescape.go:19:43: new(Example) does not escape 表示没有内存逃逸分析了。