Go 内存逃逸分析

什么是内存逃逸

在 C、C++ 中,如果需要将对象分配到堆区,我们使用 malloc 或者 new 手动分配内存,并在对象使用完毕后,手动调用 free 或者 delete 释放内存

但在 Go 中,没有这个烦恼:

  • Go 编译器会分析对象到底应该被分配在栈区,还是堆区
  • Go 的 GC 会自动回收不再使用的对象,释放内存

那么,编译器是如何判断一个对象分配的位置呢?内存逃逸分析就是用来做这个事情的

内存逃逸会造成什么影响

内存逃逸,主要还是会产生性能方面的问题

分配内存

一个对象,如果分配在栈区,那么分配内存时,只需要 brk 系统调用,移动栈顶指针即可;

如果分配在堆区,那么需要 mmap 系统调用,去 OS 申请一块内存,效率不如 brk

此外,brk 会额外申请一部分内存,减少系统调用次数,进而减少变态开销

释放内存

一个对象,如果分配在栈区,那么在函数调用完成后,会自动的将该对象占用的栈空间释放(这里需要一定的汇编基础来理解)

这意味着,我们不需要自己去释放这个对象占用的空间,这会减轻 GC 的压力(STW 时间更短)

常见内存逃逸场景

指针

package main

type Foo struct {
	A int
	B int
}

func Bar() *Foo {
	f := new(Foo)
	return f
}

func main() {
	Bar()
}

在编译时,加上 -gcflags=-m 启用逃逸分析,输出如下:

Sky_Lee@SkyLeeMBP test % go build -gcflags=-m main.go
# command-line-arguments
./main.go:8:6: can inline Bar
./main.go:13:6: can inline main
./main.go:14:5: inlining call to Bar
./main.go:9:10: new(Foo) escapes to heap
./main.go:14:5: new(Foo) does not escape

可以看到,在 return f 这里发生了内存逃逸:f 逃逸到了堆

为什么不能将 f 分配到栈?

如果 f 分配在栈区,在 Bar 调用结束后,f 的生命周期就结束了,会自动释放 f 占用的空间,那么外部的函数还要使用这个返回的 f,会产生未定义的行为

空接口(any)

package main

import "fmt"

type Foo struct {
	A int
	B int
}

func Bar() *Foo {
	f := new(Foo) // line 9
	return f
}


func main() {
	f := Bar() // line 17
	fmt.Println(f)
}

编译时输出:

Sky_Lee@SkyLeeMBP test % go build -gcflags=-m main.go
# command-line-arguments
./main.go:10:6: can inline Bar
./main.go:17:10: inlining call to Bar
./main.go:18:13: inlining call to fmt.Println
./main.go:11:10: new(Foo) escapes to heap
./main.go:17:10: new(Foo) escapes to heap
./main.go:18:13: ... argument does not escape

可以发现,在 17 行也发生了逃逸,这是因为 fmt.Println() 的参数是 any 类型,编译时无法判断其实际类型,无法确定其占用空间,只能分配在堆区

如果传递的参数是 f.A,那么 17 行不会发生逃逸,但 f.A 仍会逃逸

栈空间不足

一个进程的栈空间大小是有限的(使用 ulimit -s 查看),通常为 8192k

当一个对象的大小超过栈空间的大小,那只能分配在堆区

函数闭包

package main

import "fmt"

type Bar = func ()

func Foo() Bar  {
	n := 0
	return func() {
		n++
		fmt.Printf("n: %d\n", n)
	}
}

func main()  {

}

编译输出:

Sky_Lee@SkyLeeMBP test % go build -gcflags=-m main.go
# command-line-arguments
./main.go:7:6: can inline Foo
./main.go:11:13: inlining call to fmt.Printf
./main.go:15:6: can inline main
./main.go:8:2: moved to heap: n
./main.go:9:9: func literal escapes to heap
./main.go:11:13: ... argument does not escape
./main.go:11:25: n escapes to heap

对象 n 发生了逃逸,因为 n 的生命周期已经与返回的 Bar 一致了,只有 Bar 被销毁,n 才会随着销毁,因此,必须将 n 分配到堆区

传值还是传指针

package main

type Foo struct {
	A int
}

func Bar0(f *Foo)  {
}

func Bar1(f Foo)  {
}

func Test0()  {
	f0 := Foo{}
	f1 := Foo{}
	Bar0(&f0)
	Bar1(f1)
}

func Test1()  {
	f0 := Foo{}
	f1 := Foo{}
	go Bar0(&f0) // line: 23
	go Bar1(f1)
}

func main()  {

}

输出:

Sky_Lee@SkyLeeMBP test % go build -gcflags=-m main.go
# command-line-arguments
./main.go:7:6: can inline Bar0
./main.go:10:6: can inline Bar1
./main.go:13:6: can inline Test0
./main.go:16:6: inlining call to Bar0
./main.go:17:6: inlining call to Bar1
./main.go:27:6: can inline main
./main.go:7:11: f does not escape
./main.go:21:2: moved to heap: f0

这里不再分析为什么 Test1 的 f0 需要分配在堆区

从性能角度考虑:传值还是传指针

  • 传值:存在一次拷贝带来的性能开销
  • 传指针:仅拷贝指针本身,拷贝带来的性能影响忽略不计,但是可能逃逸到堆区

那么,在设计函数时,到底传值还是传指针呢?

  • 如果对象本身不大,拷贝带来的性能影响较小,传值,减小 GC 负担
  • 如果对象占用空间较大,无法忽略拷贝的性能开销,传指针

参考资料