目录

逃逸分析

一个程序占用的内存分为以下几个部分:

  • 栈区(stack):由编译器自动分配,存放函数的参数,局部变量等
  • 堆区(heap):由程序员分配释放
  • 全局静态区:全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域, 未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。 程序结束后由系统释放。
  • 文字常量区:常量字符串就是放在这里的。 程序结束后由系统释放。
  • 程序代码区:存放函数体的二进制代码。

OS给一个进程分配的内存空间大致可以分为:代码区全局数据区栈(stack)堆(heap)环境变量区域以及中间空白的缓冲区六个部分。其中,数据的增长路径除栈(stack)是由高到低之外,其余的均是由低到高。

那么在写go的时候,变量到底分配在栈中还是在堆中,这个就不是程序员来决定的,而是go自行处理。可能你new出来的变量在堆上,也有可能在栈上。

那么我们是否有办法知道我们写的变量位置在哪呢?

一、逃逸分析

go向开发者提供了变量逃逸分析的工具

1
$ go run -gcflags "-m -l" main.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

import "fmt"

func main() {
	a,b := 1,2
	ans := add(a,b)
	fmt.Println(ans)
}

func add(a, b int) int {
	return a + b
}

打印出来如下内容

1
2
3
.\main.go:8:13: ans escapes to heap
.\main.go:8:13: main ... argument does not escape
3

这就表明了变量ans逃逸到了堆中。

二、逃逸原理

逃逸分析(escape anslysis)就是在程序编译阶段根据程序中的数据流,对代码中哪些变量需要在栈上分配,哪些变量需要在堆上分配进行静态分析的方法。

在栈上效率肯定要比在堆上高。一个理想的逃逸分析算法自然是将能分配在栈上的变量尽可能保留在栈上,尽可能少“逃逸”到堆上。

在[cmd/compile/internal/gc/escape.go]文件中,提到了逃逸分析的设计原理。


两个不变性

  • 指向栈对象的指针不能存在堆中
  • 指向栈对象的指针不能存活超过这个栈对象(因为声明函数返回并销毁了对象的堆栈,或在循环迭代中使用局部变量)

逃逸分析的输入是go编译器解析了源文件后获取的整个程序的抽象语法树AST

1、首先,构建一个有向加权图,其中顶点(locations)表示语句和表达式分配的变量,边表示变量之间的赋值(权重表示寻址/取址次数)

2、接下来,遍历该有向加权图,在图中寻找可能违反上述两个不变性的赋值路径。如果一个变量v的地址是存储在堆上或可能超过存活期,那么v就会被标记需要在堆上分配。

3、为了支持函数间分析,算法还记录了从每个函数的参数到堆的数据流及其结果的数据流。这被称为“参数标签”,用来静态调用,以改进函数参数间的逃逸分析。


源码解析后得到的抽象语法树AST的Node切片为xtop

1
var xtop []*Node

MAIN函数中,注册了逃逸分析

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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)
}

三、如何逃逸

1
2
3
func escapes(all []*Node) {
	visitBottomUp(all, escapeFuncs)
}

这个visitBottomUp其实就是遍历有向加权图

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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)
		}
	}
}

真正分析逃逸分析的是escapeFuncs,它是对最小批的函数执行逃逸分析

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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)
}

具体逃逸分析逻辑还是比较复杂的,我们只需要知道逃逸分析是根据有向加权图根据两个不变性进行的分析。

四、实战

既然知道了这两个不变性,那我们可以对这两个不变性做一下文章。

1、简单逃逸

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func main() {
	foo()
	e,f := boo()
	println("out e: ",&e)
	println("out f: ",&f)
}

func foo() {
	a,b := 11, new(int)
	println("a: ", &a)
	println("b: ",&b)
}

func boo() (*int, *int) {
	c,d := 11,12
	println("c: ",&c)
	println("d: ",&d)
	return &c,&d
}

变量cd因为在外部被使用了,所以会逃逸,其他变量只是在函数内部使用,所以不会逃逸。

1
2
3
4
$ go run -gcflags="-m -l" main.go
.\main.go:11:16: new(int) does not escape
.\main.go:17:2: moved to heap: c
.\main.go:17:4: moved to heap: d

这个与我们分析结果一致。

2、切片逃逸

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func main() {
	noEscapesliceInHeap()
	noEscapesliceInStack()
	escapeslice()
}

func noEscapesliceInHeap() {
	var s1 []int
	println("s1: ",&s1)
	s1 = append(s1, 1,2,3)
	println("s1: ",&s1)
}

func noEscapesliceInStack() {
	var s2 = make([]int,0,4)
	println("s2: ",&s2)
	s2 = append(s2, 1,2,3)
	println("s2: ",&s2)
}

func escapeslice() *[]int {
	var s3 = make([]int, 0, 4)
	println("s3: ",&s3)
	return &s3
}

我们在slice时分析过,当slice发生扩容时会重新分配内存,这一步是在堆上操作的。

  • noEscapesliceInHeap声明空slice,并添加元素,slice本身分配在栈上,但在运行过程中动态扩容,将元素分配在堆上。
  • noEscapesliceInStack初始化了包含4个元素存储空间的切片,slice没有逃逸,当添加元素小于4个,使用栈空间,当使用超过4个,那么会发生扩容,在堆上分配更大的空间将栈上的元素拷贝过去。
  • escapeslice切片及元素都分配在堆上。
1
2
3
4
$ go run -gcflags="-m -l" main.go
.\main.go:17:15: make([]int, 0, 4) does not escape
.\main.go:24:6: moved to heap: s3
.\main.go:24:15: make([]int, 0, 4) escapes to heap

3、fmt逃逸

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {
	foo()
}

func foo() {
	a,b := 11,12
	println("a: ",&a)
	println("b: ",&b)
	fmt.Printf("a:%d ",&a)
}
1
2
3
$ go run -gcflags="-m -l" main.go
.\main.go:10:2: moved to heap: a
.\main.go:13:12: ... argument does not escape

此时变量a还没有逃逸

1
2
3
4
5
6
func foo() {
	a,b := 11,12
	println("a: ",&a)
	println("b: ",&b)
	fmt.Printf("a:%d ",a)
}
1
2
3
$ go run -gcflags="-m -l" main.go
.\main.go:13:12: ... argument does not escape
.\main.go:13:13: a escapes to heap

此时变量a已经逃逸

4、手动避免逃逸

在源码中,有这么一个函数

1
2
3
4
func noescape(p unsafe.Pointer) unsafe.Pointer {
	x := uintptr(p)
	return unsafe.Pointer(x ^ 0) // 任何数值与0的异或都是原数
}

实现逻辑使得我们传入的指针值与其返回的指针值一样,只是通过uintptr做了一次转换,而这次转换将指针转换成数值,“切断”了逃逸分析的数据流,导致传入的指针避免逃逸。

那么我们也可以使用这个函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func noescape(p unsafe.Pointer) unsafe.Pointer {
	x := uintptr(p)
	return unsafe.Pointer(x ^ 0) // 任何数值与0的异或都是原数
}

func foo() {
	a := 11
	b := 12
	fmt.Printf("a=%d\n",noescape(unsafe.Pointer(&a)))
	println("addr of a: ",&a)
	println("addr of b: ",&b)
}

func main() {
	foo()
}
1
2
3
4
$ go run -gcflags="-m -l" main.go
.\main.go:7:15: p does not escape
.\main.go:13:2: moved to heap: a
.\main.go:14:2: moved to heap: b

这样就变量a就不会逃逸了