通过实例理解Go逃逸分析 - 图1

翻看了一下自己的 Go 文章归档[1],发现自己从未专门写过有关 Go 逃逸分析(escape analysis)的文章。关于 Go 变量的逃逸分析,大多数 Gopher 其实并不用关心,甚至可以无视。但是如果你将 Go 应用于性能敏感的领域,要完全压榨出 Go 应用的性能,那么理解 Go 逃逸分析就大有裨益了。在本文,我们就一起来理解一下 Go 的逃逸分析。

1. 逃逸分析(escape analysis)要解决的问题

C/C++ 语言出身的程序员[2]对堆内存(heap)和栈内存(stack)都有着 “泾渭分明” 的理解。在操作系统演化出现进程虚拟内存地址(virtual memory address)的概念后,如下图所示,应用程序的虚拟内存地址空间就被划分为堆内存区(如图中的 heap)和栈内存区(如图中的 stack):

通过实例理解Go逃逸分析 - 图2

图:一个进程的虚拟内存地址空间(图来自https://dave.cheney.net/2014/06/07/five-things-that-make-go-fast)

在 x86 平台 linux 操作系统下,如上图,一般将栈内存区放在高地址,栈向下延伸;而堆内存去放在低地址,堆向上延伸,这样做的好处就是便于堆和栈可动态共享那段内存区域。

这是否意味着所有分配在堆内存区域的内存对象地址一定比分配在栈内存区域的内存对象地址要小呢?在 C/C++ 中是这样的,但是在 Go 语言中,这是不一定的,因为 go 堆内存所使用的内存页 (page) 与 goroutine 的栈所使用的内存页是交织在一起的[3]。

无论是栈内存还是堆内存,对于应用而言都是合法可用的内存地址空间。之所以将其区分开,是因为应用程序的内存分配和管理的需要。

栈内存上的对象的存储空间是自动分配和销毁的,无需开发人员或编程语言运行时过多参与,比如下面的这段 C 代码(用 C 代码更能体现栈内存与堆内存的差别):

`// github.com/bigwhite/experiments/blob/master/go-escape-analysis/c/cstack.c

include

void bar() {
int e = 31;
int f = 32;
printf(“e = %d\n”, e);
printf(“f = %d\n”, f);
}

void foo() {
int c = 21;
int d = 22;
printf(“c = %d\n”, c);
printf(“d = %d\n”, d);
}

int main() {
int a = 11;
int b = 12;
printf(“a = %d\n”, a);
printf(“b = %d\n”, b);
foo();
bar();
}

`

上面这段 c 程序算上 main 函数共有三个函数,每个函数中都有两个整型变量,C 编译器自动为这些变量在栈内存上分配空间,我们无需考虑它什么时候被创建以及何时被销毁,我们只需在特定的作用域(其所在函数内部)使用它即可,而无需担心其内存地址不合法,因此这些被分配在栈内存上的变量也被称为 “自动变量”。但是如果将其地址返回到函数的外部,那么函数外部的代码通过解引用而访问这些变量时便会出错,如下面示例:

`// github.com/bigwhite/experiments/blob/master/go-escape-analysis/c/cstack_coredump.c

include

int *foo() {
int c = 11;
return &c;
}

int main() {
int _p = foo();
printf(“the return value of foo = %d\n”, _p);
}

`

如代码所示,在上面这个例子中,我们将 foo 函数内的自动变量 c 的地址通过函数返回值返回给 foo 函数的调用者(main)了,这样当我们在 main 函数中引用该地址输出该变量值的时候,我们就会收到异常,比如在 ubuntu 上运行上述程序,我们会得到如下结果(在 macos 上运行,gcc 会给出相同的警告,但程序运行不会 dump core):

# gcc cstack_dumpcore.c cstack_dumpcore.c: In function ‘foo’: cstack_dumpcore.c:5:12: warning: function returns address of local variable [-Wreturn-local-addr] return &c; ^~ # ./a.out Segmentation fault (core dumped)

这样一来我们就需要一种内存对象,可以在全局(跨函数间)合法使用,这就是堆内存对象。但是和位于栈上的内存对象由程序自行创建销毁不同,堆内存对象需要通过专用 API 手工分配和释放,在 C 中对应的分配和释放方法就是 malloc 和 free:

`// github.com/bigwhite/experiments/blob/master/go-escape-analysis/c/cheap.c

include

include

int _foo() {
int _c = malloc(sizeof(int));
*c = 12;
return c;
}

int main() {
int _p = foo();
printf(“the return value of foo = %d\n”, _p);
free(p);
}

`

在这个示例中我们使用 malloc 在 foo 函数中分配了一个堆内存对象,并将该对象返回给 main 函数,main 函数使用完该对象后调用了 free 函数手工释放了该堆内存块。

显然和自动变量相比,堆内存对象的生命周期管理将会给开发人员带来很大的心智负担。为了降低这方面的心智负担,带有 GC(垃圾回收)的编程语言出现了,比如 Java、Go 等。这些带有 GC 的编程语言会对位于堆上的对象进行自动管理。当某个对象不可达时(即没有其对象引用它时),它将会被回收并被重用。

但 GC 的出现虽然降低了开发人员在内存管理方面的心智负担,但 GC 不是免费的,它给程序带来的性能损耗是不可忽视的,尤其是当堆内存上有大量待扫描的堆内存对象时,将会给 GC 带来过大的压力,从而使得 GC 占用更多本应用于处理业务逻辑的计算和存储资源。于是人们开始想方法尽量减少在堆上的内存分配,可以在栈上分配的变量尽量留在栈上。

逃逸分析(escape analysis)就是在程序编译阶段根据程序代码中的数据流,对代码中哪些变量需要在栈上分配,哪些变量需要在堆上分配进行静态分析的方法。一个理想的逃逸分析算法自然是能将那些人们认为需要分配在栈上的变量尽可能保留在栈上,尽可能少的 “逃逸” 到堆上的算法。但这太过理想,各种语言都有自己的特殊情况,各种语言的逃逸算法的精确度实际都会受到这方面的影响。

2. Go 语言的逃逸分析

Go 从诞生那天[4]起,逃逸分析就始终伴随其左右。正如上面说到的逃逸分析的目标,Go 编译器使用逃逸分析来决定哪些变量应该在 goroutine 的栈上分配,哪些变量应该在堆上分配。

截至目前,Go 一共有两个版本的逃逸分析实现,分水岭在 Go 1.13 版本[5]重写的第二版逃逸分析[6],并默认开启,可以通过 - gcflags=”-m -newescape=false” 恢复到使用第一版逃逸分析。之所以重写,主要是考虑第一版代码的可读性和可维护性问题,新版代码主要位于 Go 项目源码的 src/cmd/compile/internal/gc/escape.go 中,它将逃逸分析代码从上一版的 2400 多行缩减为 1600 多行,并作了更为完整文档和注释。但注意的是新版代码在算法精确性上并没有质的变化

但即便如此,经过了这么多年的 “修修补补”,Dmitry Vyukov 2015 年提出的那些 “Go Escape Analysis Flaws”[7]多数已经 fix 了。Go 项目中内置了对逃逸分析的详尽的测试代码(位于 Go 项目下的 test/escape*.go 文件中)。

在新版逃逸分析实现的注释中($GOROOT/src/cmd/compile/internal/gc/escape.go),我们可以大致了解逃逸分析的实现原理。注释中的原理说明中提到了算法基于的两个不变性:

  1. 指向栈对象的指针不能存储在堆中(pointers to stack objects cannot be stored in the heap);
  2. 指向栈对象的指针不能超过该栈对象的存活期(即指针不能在栈对象被销毁后依旧存活)(pointers to a stack object cannot outlive that object)。

源码注释中也给出 Go 逃逸分析的大致原理和过程。Go 逃逸分析的输入是 Go 编译器解析了 Go 源文件后所获得的整个程序的抽象语法树(Abstract syntax tree,AST):

源码解析后得到的代码 AST 的 Node 切片为 xtop:

// $GOROOT/src/cmd/compile/internal/gc/go.go var xtop []*Node

在 Main 函数中,xtop 被传入逃逸分析的入口函数 escapes:

`// $GOROOT/src/cmd/compile/internal/gc/main.go

// Main parses flags and Go source files specified in the command-line
// arguments, type-checks the parsed Go package, compiles functions to machine
// code, and finally writes the compiled package definition to disk.
func Main(archInit func(*Arch)) {
… …
// Phase 6: Escape analysis.
// Required for moving heap allocations onto stack,
// which in turn is required by the closure implementation,
// which stores the addresses of stack variables into the closure.
// If the closure does not escape, it needs to be on the stack
// or else the stack copier will not update it.
// Large values are also moved off stack in escape analysis;
// because large values may contain pointers, it must happen early.
timings.Start(“fe”, “escapes”)
escapes(xtop)
… …
}

`

下面是 escapes 函数的实现:

`// $GOROOT/src/cmd/compile/internal/gc/esc.go
func escapes(all []*Node) {
visitBottomUp(all, escapeFuncs)
}

// $GOROOT/src/cmd/compile/internal/gc/scc.go
// 强连接 node - 一个数据结构
func visitBottomUp(list []Node, analyze func(list []_Node, recursive bool)) {
var v bottomUpVisitor
v.analyze = analyze
v.nodeID = make(map[*Node]uint32)
for
, n := range list {
if n.Op == ODCLFUNC && !n.Func.IsHiddenClosure() {
v.visit(n)
}
}
}

// $GOROOT/src/cmd/compile/internal/gc/escape.go

// escapeFuncs performs escape analysis on a minimal batch of
// functions.
func escapeFuncs(fns []*Node, recursive bool) {
for _, fn := range fns {
if fn.Op != ODCLFUNC {
Fatalf(“unexpected node: %v”, fn)
}
}

var e Escape
e.heapLoc.escapes = true

// Construct data-flow graph from syntax trees.
for , fn := range fns {
e.initFunc(fn)
}
for
, fn := range fns {
e.walkFunc(fn)
}
e.curfn = nil

e.walkAll()
e.finish(fns)
}

`

根据注释,escapes 的大致原理是 (直译):

  • 首先,构建一个有向加权图,其中顶点 (称为 “location”,由 gc.EscLocation 表示) 代表由语句和表达式分配的变量,而边 (gc.EscEdge) 代表变量之间的赋值(权重代表寻址 / 取地址次数)。
  • 接下来,遍历 (visitBottomUp) 该有向加权图,在图中寻找可能违反上述两个不变量条件的赋值路径。违反上述不变量的赋值路径。如果一个变量 v 的地址是储存在堆或其他可能会超过它的存活期的地方,那么 v 就会被标记为需要在堆上分配。
  • 为了支持函数间的分析,算法还记录了从每个函数的参数到堆的数据流以及到其结果的数据流。算法将这些信息称为 “参数标签 (parameter tag)”。这些标签信息在静态调用时使用,以改善对函数参数的逃逸分析。

当然即便看到这,你可能依旧一头雾水,没关系,这里不是讲解逃逸分析原理,如果想了解原理,那就请认真阅读那 2400 多行代码。

注:有一点需要明确,那就是静态逃逸分析也无法确定的对象会被放置在堆上,后续精确的 GC 会处理这些对象,这样最大程度保证了代码的安全性。

3. Go 逃逸分析的示例

Go 工具链提供了查看逃逸分析过程的方法,我们可以通过在 - gcflags 中使用 - m 来让 Go 编译器输出逃逸分析的过程,下面是一些典型的示例。

1) 简单原生类型变量的逃逸分析

我们来看一个原生整型变量的逃逸分析过程,下面是示例的代码:

// github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/int.go 1 package main 2 3 import "testing" 4 5 func foo() { 6 a := 11 7 p := new(int) 8 *p = 12 9 println("addr of a is", &a) 10 println("addr that p point to is", p) 11 } 12 13 func bar() (*int, *int) { 14 m := 21 15 n := 22 16 println("addr of m is", &m) 17 println("addr of n is", &n) 18 return &m, &n 19 } 20 21 func main() { 22 println(int(testing.AllocsPerRun(1, foo))) 23 println(int(testing.AllocsPerRun(1, func() { 24 bar() 25 }))) 26 }

我们通过 - gcflags “-m -l” 来执行逃逸分析,之所以传入 - l 是为了关闭 inline,屏蔽掉 inline 对这个过程以及最终代码生成的影响:

// go 1.16 版本 on MacOS $go build -gcflags "-m -l" int.go # command-line-arguments ./int.go:7:10: new(int) does not escape ./int.go:14:2: moved to heap: m ./int.go:15:2: moved to heap: n ./int.go:23:38: func literal does not escape

逃逸分析的结果与我们手工分析的一致:函数 bar 中的 m、n 逃逸到 heap(对应上面输出的有 moved to heap: xx 字样的行),这两个变量将在 heap 上被分配存储空间。而函数 foo 中的 a 以及指针 p 指向的内存块都在栈上分配(即便我们是调用的 new 创建的 int 对象,Go 中 new 出来的对象可不一定分配在堆上,逃逸分析的输出日志中还专门提及 new(int) 没有逃逸)。我们执行一下该示例(执行时同样传入 - l 关闭 inline):

$go run -gcflags "-l" int.go addr of a is 0xc000074860 addr that p point to is 0xc000074868 addr of a is 0xc000074860 addr that p point to is 0xc000074868 0 addr of m is 0xc0000160e0 addr of n is 0xc0000160e8 addr of m is 0xc0000160f0 addr of n is 0xc0000160f8 2

首先,我们看到未逃逸的 a 和 p 指向的内存块的地址区域在 0xc000074860~0xc000074868;而逃逸的 m 和 n 被分配到了堆内存空间,从输出的结果来看在 0xc0000160e0~0xc0000160e8。我们可以明显看到这是两块不同的内存地址空间;另外通过 testing 包的 AllocsPerRun 的输出,我们同样印证了函数 bar 中执行了两次堆内存分配动作。

我们再来看看这个代码对应的汇编代码:

$go tool compile -S int.go |grep new 0x002c 00044 (int.go:14) CALL runtime.newobject(SB) 0x004d 00077 (int.go:15) CALL runtime.newobject(SB) rel 45+4 t=8 runtime.newobject+0 rel 78+4 t=8 runtime.newobject+0

我们看到在对应源码的 14 和 15 行,汇编调用了 runtime.newobject 在堆上执行了内存分配动作,这恰是逃逸的 m 和 n 声明的位置。从下面 newobject 代码的实现我们也能看到,它实际上在 gc 管理的内存上执行了 malloc 动作:

`// $GOROOT/src/runtime/malloc.go

// implementation of new builtin
// compiler (both frontend and SSA backend) knows the signature
// of this function
func newobject(typ *_type) unsafe.Pointer {
return mallocgc(typ.size, typ, true)
}

`

2) 切片变量自身和切片元素的逃逸分析

了解过切片实现原理[8]的 gopher 都知道,切片变量实质上是一个三元组:

`//$GOROOT/src/runtime/slice.go

type slice struct {
array unsafe.Pointer
len int
cap int
}

`

其中这个三元组的第一个字段 array 指向的是切片底层真正存储元素的指针。这样当为一个切片变量分配内存时,便既要考虑切片本身 (即上面的 slice 结构体) 在哪里分配,也要考虑切片元素的存储在哪里分配。我们看下面示例:

`// github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/slice.go

1 package main
2
3 import (
4 “reflect”
5 “unsafe”
6 )
7
8 func noEscapeSliceWithDataInHeap() {
9 var sl []int
10 println(“addr of local(noescape, data in heap) slice =”, &sl)
11 printSliceHeader(&sl)
12 sl = append(sl, 1)
13 println(“append 1”)
14 printSliceHeader(&sl)
15 println(“append 2”)
16 sl = append(sl, 2)
17 printSliceHeader(&sl)
18 println(“append 3”)
19 sl = append(sl, 3)
20 printSliceHeader(&sl)
21 println(“append 4”)
22 sl = append(sl, 4)
23 printSliceHeader(&sl)
24 }
25
26 func noEscapeSliceWithDataInStack() {
27 var sl = make([]int, 0, 8)
28 println(“addr of local(noescape, data in stack) slice =”, &sl)
29 printSliceHeader(&sl)
30 sl = append(sl, 1)
31 println(“append 1”)
32 printSliceHeader(&sl)
33 sl = append(sl, 2)
34 println(“append 2”)
35 printSliceHeader(&sl)
36 }
37
38 func escapeSlice() []int {
39 var sl = make([]int, 0, 8)
40 println(“addr of local(escape) slice =”, &sl)
41 printSliceHeader(&sl)
42 sl = append(sl, 1)
43 println(“append 1”)
44 printSliceHeader(&sl)
45 sl = append(sl, 2)
46 println(“append 2”)
47 printSliceHeader(&sl)
48 return &sl
49 }
50
51 func printSliceHeader(p
[]int) {
52 ph := (*reflect.SliceHeader)(unsafe.Pointer(p))
53 println(“slice data =”, unsafe.Pointer(ph.Data))
54 }
55
56 func main() {
57 noEscapeSliceWithDataInHeap()
58 noEscapeSliceWithDataInStack()
59 escapeSlice()
60 }

`

对上述示例运行逃逸分析:

$go build -gcflags "-m -l" slice.go # command-line-arguments ./slice.go:51:23: p does not escape ./slice.go:27:15: make([]int, 0, 8) does not escape ./slice.go:39:6: moved to heap: sl ./slice.go:39:15: make([]int, 0, 8) escapes to heap

我们从输出的信息中看到:

  • 位于 39 行的 escapeSlice 函数中的 sl 逃逸到堆上了;
  • 位于 39 行的 escapeSlice 函数中的切片 sl 的元素也逃逸到堆上了;
  • 位于 27 行的切片 sl 的元素没有逃逸。

由于很难看到三个函数中各个切片的元素是否逃逸,我们通过运行该示例来看一下:

`$go run -gcflags “-l” slice.go
addr of local(noescape, data in heap) slice = 0xc00006af48
slice data = 0x0
append 1
slice data = 0xc0000160c0
append 2
slice data = 0xc0000160d0
append 3
slice data = 0xc0000140c0
append 4
slice data = 0xc0000140c0

addr of local(noescape, data in stack) slice = 0xc00006af48
slice data = 0xc00006af08
append 1
slice data = 0xc00006af08
append 2
slice data = 0xc00006af08

addr of local(escape) slice = 0xc00000c030
slice data = 0xc00001a100
append 1
slice data = 0xc00001a100
append 2
slice data = 0xc00001a100

`

注:我们利用 reflect 包的 SliceHeader 输出切片三元组中的代表底层数组地址的字段,这里是 slice data。

我们看到:

  • 第一个函数 noEscapeWithDataInHeap 声明了一个空 slice,并在后面使用 append 向切片附加元素。从输出结果来看,slice 自身是分配在栈上的,但是运行时在动态扩展切片时,选择了将其元素存储在 heap 上;
  • 第二个函数 noEscapeWithDataInStack 直接初始化了一个包含 8 个元素存储空间的切片,切片自身没有逃逸,并且在附加 (append) 的元素个数小于等于 8 个的时候,元素直接使用了为其分配的栈空间;但如果附加的元素超过 8 个,那么运行时会在堆上分配一个更大的空间并将原栈上的 8 个元素复制过去,后续该切片的元素就都存储在了堆上。这也是为什么强烈建议在创建 slice 时带上预估的 cap 参数的原因[9],不仅减少了堆内存的频繁分配,在切片变量未逃逸的情况下,在 cap 容量之下,所有元素都分配在栈上,这将提升运行性能。
  • 第三个函数 escapeSlice 则是切片变量自身以及其元素的存储都在堆上。

3) fmt.Printf 系列函数让变量逃逸到堆 (heap) 上了?

很多人在 go 项目的 issue 中反馈 fmt.Printf 系列函数让变量逃逸到堆上了,情况真的是这样么?我们通过下面示例来看一下:

// github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/printf1.go 1 package main 2 3 import "fmt" 4 5 func foo() { 6 var a int = 66666666 7 var b int = 77 8 fmt.Printf("a = %d\n", a) 9 println("addr of a in foo =", &a) 10 println("addr of b in foo =", &b) 11 } 12 13 func main() { 14 foo() 15 }

注:println 和 print 两个预定义函数并没有像 fmt.Printf 系列函数的 “副作用”,不会影响变量的逃逸性。所以这里使用 println 来输出变量的实际分配内存地址。

对上面的代码运行逃逸分析:

$go build -gcflags "-m -l" printf1.go # command-line-arguments ./printf1.go:8:12: ... argument does not escape ./printf1.go:8:13: a escapes to heap

我们看到逃逸分析输出第 8 行的变量 “a escapes to heap”,不过这个“逃逸” 有些奇怪,因为按照之前的经验,如果某个变量真实逃逸了,那么逃逸分析会在其声明的那行输出:“moved to heap: xx”字样。而上面这个输出既不是在变量声明的那一行,也没有输出 “moved to heap: a” 字样,变量 a 真的逃逸了么?我们运行一下上面示例,看看变量 a 的地址究竟是在堆上还是栈上:

$go run -gcflags "-l" printf1.go a = 66666666 addr of a in foo = 0xc000092f50 addr of b in foo = 0xc000092f48

我们看到变量 a 的地址与未逃逸的变量 b 的地址都在同一个栈空间,变量 a 并未逃逸!如果你反编译为汇编,你肯定也看不到 runtime.newobject 的调用。

那么 “./printf1.go:8:13: a escapes to heap” 这句的含义究竟是什么呢?显然逃逸分析在这一行是对进入 fmt.Printf 的数据流的分析,我们修改一下 go 标准库源码,然后 build -a 重新编译一下 printf1.go[10],看看在 fmt.Printf 内部变量的分布情况:

`// $GOROOT/src/fmt/print.go

func Printf(format string, a …interface{}) (n int, err error) {
// 添加下面四行代码
for i := 0; i println(a[i])
println(&a[i])
}
return Fprintf(os.Stdout, format, a…)
}

`

重新编译 printf1.go 并运行编译后的可执行文件 (为了避免):

$go build -a -gcflags "-l" printf1.go $./printf1 (0x10af200,0xc0000160c8) 0xc00006cf58 a = 66666666 addr of a in foo = 0xc00006cf50 addr of b in foo = 0xc00006cf48

我们看到 fmt.Printf 的实参 a 在传入后被装箱到一个 interface{}类型的形参变量中,而这个形参变量自身则是被分配在栈上的(0xc00006cf58),而通过 println 输出的该 interface{}类型形参变量的类型部分和值部分分别指向 0x10af200 和 0xc0000160c8。显然值部分是在堆内存上分配的。那么 “./printf1.go:8:13: a escapes to heap” 是否指的是装箱后的值部分在堆上分配呢?这里也不确定。

我们再来看一个例子来对比一下:

// github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/printf2.go 1 package main 2 3 import "fmt" 4 5 func foo() { 6 var a int = 66666666 7 var b int = 77 8 fmt.Printf("addr of a in bar = %p\n", &a) 9 println("addr of a in bar =", &a) 10 println("addr of b in bar =", &b) 11 } 12 13 func main() { 14 foo() 15 }

在 printf2.go 这个例子中,与 printf1.go 不同的是我们在 foo 函数中使用 fmt.Printf 输出的是变量 a 的地址:&a。我们运行一下新版逃逸分析:

`// go 1.16

$go build -gcflags “-m -l” printf2.go

command-line-arguments

./printf2.go:6:6: moved to heap: a
./printf2.go:8:12: … argument does not escape

`

我们看到位于第 6 行声明的变量 a 居然真的逃逸到了堆上。我们运行一下 printf2.go:

$go build -a -gcflags "-l" printf2.go $./printf2 (0x10ab4a0,0xc0000160c8) 0xc00006cf58 addr of a in bar = 0xc0000160c8 addr of a in bar = 0xc0000160c8 addr of b in bar = 0xc00006cf48

我们看到变量 a 的地址果然与位于栈上的变量 b 相差很大,应该就是在堆上,那么这样看那些在 go 项目中提 issue 的 gopher 所言不虚。变量 a 的地址以实参的形式传入 fmt.Printf 后被装箱到一个 interface{} 形参变量中,而从结果来看,fmt.Printf 真的要求装箱的形参变量的值部分要在堆上分配,但根据逃逸分析不变性,堆上的对象不能存储一个栈上的地址,而这次存储的是 a 的地址,于是将 a 判定为逃逸,于是 a 自身也就被分配到了堆上 (0xc0000160c8)。

我们用 go 1.12.7 运行一下老版的逃逸分析:

// go 1.12.7 $go build -gcflags "-m -l" printf2.go # command-line-arguments ./printf2.go:8:40: &a escapes to heap ./printf2.go:8:40: &a escapes to heap ./printf2.go:6:6: moved to heap: a ./printf2.go:8:12: foo ... argument does not escape ./printf2.go:9:32: foo &a does not escape ./printf2.go:10:32: foo &b does not escape

老版的逃逸分析给出了更详细的输出,比如:“&a escapes to heap”,其所指想必就是 & a 被装箱到堆内存上;而 println 输出 & a 则无需 & a 被装箱。但此后对变量 a 的最终判定为逃逸。

Go 核心团队成员 Keith Randall[11]对逃逸分析输出的日志给过一个解释[12],大致意思是:当逃逸分析输出 “b escapes to heap” 时,意思是指存储在 b 中的值逃逸到堆上了(当 b 为指针变量时才有意义),即任何被 b 引用的对象必须分配在堆上,而 b 自身则不需要;如果 b 自身也逃逸到堆上,那么逃逸分析会输出“&b escapes to heap”。

这个问题目前已经没有 fix,其核心问题在 8618 这个 issue[13]中。

5. 手动强制避免逃逸

对于 printf2.go 中的例子,我们确定一定以及肯定:a 不需要逃逸。但若使用 fmt.Printf,我们无法阻拦 a 的逃逸。那是否有一种方法可以干扰逃逸分析,使逃逸分析认为需要在堆上分配的内存对象而我们确定认为不需要逃逸的对象避免逃逸呢?在 Go 运行时代码中,我们发现了一个函数:

// $GOROOT/src/runtime/stubs.go func noescape(p unsafe.Pointer) unsafe.Pointer { x := uintptr(p) return unsafe.Pointer(x ^ 0) // 任何数值与 0 的异或都是原数 }

并且在 Go 标准库和运行时实现中,该函数得到大量使用。该函数的实现逻辑使得我们传入的指针值与其返回的指针值是一样的。该函数只是通过 uintptr 做了一次转换,而这次转换将指针转换成了数值,这 “切断” 了逃逸分析的数据流跟踪,导致传入的指针避免逃逸。

我们看一下下面例子:

`// github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/printf3.go
package main

import (
“fmt”
“unsafe”
)

func noescape(p unsafe.Pointer) unsafe.Pointer {
x := uintptr(p)
return unsafe.Pointer(x ^ 0)
}

func foo() {
var a int = 66666666
var b int = 77
fmt.Printf(“addr of a in bar = %p\n”, (*int)(noescape(unsafe.Pointer(&a))))
println(“addr of a in bar =”, &a)
println(“addr of b in bar =”, &b)
}

func main() {
foo()
}

`

对该代码实施统一分析:

`$go build -gcflags “-m -l” printf3.go

command-line-arguments

./printf3.go:8:15: p does not escape
./printf3.go:16:12: … argument does not escape

`

我们看到 a 这次没有逃逸。运行一下编译后的可执行文件:

$./printf3 (0x10ab4c0,0xc00009af50) 0xc00009af58 addr of a in bar = 0xc00009af50 addr of a in bar = 0xc00009af50 addr of b in bar = 0xc00009af48

我们看到 a 没有像 printf2.go 那样被放在堆上,这次和 b 一样都是在栈上分配的。并且在 fmt.Printf 执行的过程中 a 的栈地址始终是有效的。

曾有一篇通过逃逸分析优化性能的论文[14]《Escape from Escape Analysis of Golang》使用的就是上述 noescape 函数的思路,有兴趣的童鞋可以自行下载阅读。

6. 小结

通过这篇文章,我们了解到了逃逸分析要解决的问题、Go 逃逸分析的现状与简单原理、一些 Go 逃逸分析的实例以及对逃逸分析输出日志的说明。最后,我们给出一个强制避开逃逸分析的方案,但要谨慎使用。

日常 go 开发过程,绝大多数情况无需考虑逃逸分析,除非性能敏感的领域。在这些领域,对系统执行热点路径做一次逃逸分析以及相应的优化,可能回带来程序性能的一定提升。

本文涉及的源码可以在这里[15]下载:https://github.com/bigwhite/experiments/blob/master/go-escape-analysis

参考资料

[1]

Go 文章归档: https://tonybai.com/tag/go

[2]

C/C++ 语言出身的程序员: https://tonybai.com/tag/c

[3]

go 堆内存所使用的内存页 (page) 与 goroutine 的栈所使用的内存页是交织在一起的: https://github.com/golang/go/issues/30554#issuecomment-469141498

[4]

Go 从诞生那天: https://www.imooc.com/read/87/article/2320

[5]

Go 1.13 版本: https://mp.weixin.qq.com/s/Txqvanb17LYQYgohNiUHig

[6]

第二版逃逸分析: https://github.com/golang/go/issues/23109

[7]

“Go Escape Analysis Flaws”: https://docs.google.com/document/d/1CxgUBPlx9iJzkz9JWkb6tIpTe5q32QDmz8l0BouG0Cw/preview#

[8]

切片实现原理: https://www.imooc.com/read/87/article/2383

[9]

为什么强烈建议在创建 slice 时带上预估的 cap 参数的原因: https://www.imooc.com/read/87/article/2383

[10]

build -a 重新编译一下 printf1.go: https://www.imooc.com/read/87/article/2387

[11]

Keith Randall: https://github.com/randall77

[12]

解释: https://github.com/golang/go/issues/30554#issuecomment-469117368

[13]

8618 这个 issue: https://github.com/golang/go/issues/8618

[14]

通过逃逸分析优化性能的论文: http://www.wingtecher.com/themes/WingTecherResearch/assets/papers/ICSE20.pdf

[15]

这里: https://github.com/bigwhite/experiments/blob/master/go-escape-analysis

[16]

改善 Go 语⾔编程质量的 50 个有效实践: https://www.imooc.com/read/87

[17]

Kubernetes 实战:高可用集群搭建、配置、运维与应用: https://coding.imooc.com/class/284.html

[18]

我爱发短信: https://51smspush.com/

[19]

链接地址: https://m.do.co/c/bff6eed92687
https://mp.weixin.qq.com/s?__biz=MzAxMTA4Njc0OQ==&mid=2651449591&idx=3&sn=c5eba0fc5cd4168565830d0a3c5436fb&chksm=80bb3605b7ccbf1352965e5cf2e577ce59732426dff2b0b4e5fb4d48c52c2d88d009b39d8476&scene=21#wechat_redirect