Golang逃逸分析

转载于go语言局部变量分配在栈还是堆golang 逃逸分析详解

一段代码引发的思考?

package main

func foo(m0 int) *int {
	var m1 int = 11
	return &m1
}

func main() {
	m := foo(100)
	println(*m)
}

运行结果:

$ go build main.go && ./main 
11

竟然没有出现任何编译错误。
熟悉C/C++语言的同学,立马就能看出这个程序是存在"问题"的,即函数foo把局部变量m1的地址给返回了,当函数foo返回的时候,m1的地址是会消亡的,这个代码有很严重的问题,一般的C/C++编译器都会给出警告错误:

#include <stdio.h>

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

int main() {
  int * m = foo(100);
  printf("%d\n", *m);
}
$
$
$ gcc main.c 
main.c:8:11: warning: address of stack memory associated with local variable 'm1' returned [-Wreturn-stack-address]
  return &m1;
          ^~
1 warning generated.

如上C/C++编译器明确给出了警告,foo把一个局部变量的地址返回了;反而高大上的golang没有给出任何警告,难道go编译器时别不出这个问题吗?

引用Go FAQ里面的一段话:

How do I know whether a variable is allocated on the heap or the stack?

From a correctness standpoint, you don't need to konw. Each variable in Go exists as long as there are references to it. The storage location chosen by the implementation is irrelevant to the semantics of the language.

The storage location does have an effect on writing efficient programs. When possible, the Go compilers will allocate variables that are local to a function in that function's stack frame. However, if the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap to avoid dangling pointer errors. Also, if a local variable is very large, it might make more sense to store it on the heap rather than the stack. 

In the current compilers, if a variable has its address taken, that variable is a candidate for allocation on the heap. However, a basic escape analysis recongizes some cases when such variables will not live past the return from the function and can reside on the stack.

意思就是说golang编译器会自动决定一个变量是放在栈上还是放在堆上,编译器会做逃逸分析,当发现变量的作用域没有跑出函数范围,就可以在栈上,反之则必须分配在堆上。

go语言声称这样可以释放程序员关于内存的使用限制,更多的让程序员关注于程序功能逻辑本身。

函数传参指针?结构体?

是否应该像C一样,函数传参不要直接传结构体而是要传结构体指针?答案是未必,在大多刷情况下还是会选择传入结构体本身的。那么就有有疑问,这样不是会有不必要的内存copy开销吗?是的,确实会有,但是同样可以减缓gc压力,因为传值会在栈上分配,而一旦传指针,结构体就会逃逸到堆上了。

什么是逃逸分析?

在计算机语言编译器优化原理中,逃逸分析是指分析指针动态范围的方法,它同编译器优化原理的指针分析和外形分析相关联。当变量(或者对象)在方法中分配后,其指针有可能被返回或者被全局引用,这样就会被其他过程或者线程所引用,这种现象称作指针(或者引用)的逃逸(Escape)。

你学java时,老师在讲解jvm内存结构可能跟你说过这样一句话:“new出来的东西都在堆上,栈上存的是它的引用。”其实在现代JVM上这句话是不准确的,因为逃逸分析机制。

简单来说JVM的逃逸分析会在运行时检测当前方法栈帧内new出来的对象的引用是否被传出当前栈帧,传出则发生逃逸,未传出则未发生逃逸,例如:

public void test(){
	List<Integer> a = new ArrayList<>();
	a.add(1); // a 未发生逃逸,因此在栈上分配
}

public List<Integer> test1(){
	List<Integer> a = new ArrayList<>();
	a.add(1);
	return a  //a 发生逃逸,因此分配在堆上
}

对于未发生逃逸的变量,则直接在栈上分配内存。因此栈上内存由在函数返回时自动回收,因此能减小gc压力。

golang中的逃逸分析

首先要明确几点:

  • 不同于jvm的运行时逃逸分析,golang的逃逸分析是在编译期完成的。
  • Golang的逃逸分析只针对指针。一个值引用变量如果没有被取址,那么它永远不可能逃逸。

另外,验证某个函数的变量是否发生逃逸的方法有两个:

  • go run -gcflags "-m -l" (-m打印逃逸分析信息,-l禁止内联编译)
  • go tool compile -S main.go | grep runtime.newobject(汇编代码中搜runtime.newobject指令,该指令用于生成堆对象)

关于-gcflags "-m -l"的输出,有两种情况

moved to heap:XXX
XXX escapes to heap

二者都表示发生逃逸,当xxx变量类型为指针时,出现下一种;当xxx变量为值类型时,为上一种。

那么究竟什么时候,什么情况下会发生逃逸呢?下面就是本文所主要探究的内容。

情况1

在某个函数中new或字面量创建出的变量,将其指针作为函数返回值,则该变量一定发生逃逸。

func test() *User {
	a := User{}
	return &a
}

情况2

被已经逃逸的变量引用的指针,一定发生逃逸。

情况3

func main() {
	a := make([]*int, 1)
	b := 12
	a[0] = &b
}

结果:

➜  testProj go run -gcflags "-m -l" main.go
# command-line-arguments
./main.go:7:2: moved to heap: b
./main.go:6:11: main make([]*int, 1) does not escape

sliace a并没有发生逃逸,但是被a引用的b依然逃逸了。类似的情况同样发生在map和chan中:

func main() {
	a := make([]*int,1)
	b := 12
	a[0] = &b

	c := make(map[string]*int)
	d := 14
	c["aaa"]=&d

	e := make(chan *int,1)
	f := 15
	e <- &f
}

结果:

➜  testProj go run -gcflags "-m -l" main.go
# command-line-arguments
./main.go:7:2: moved to heap: b
./main.go:11:2: moved to heap: d
./main.go:15:2: moved to heap: f
./main.go:6:11: main make([]*int, 1) does not escape
./main.go:10:11: main make(map[string]*int) does not escape

由此我们可以得出结论:

被指针类型的slice、map和chan引用的指针一定发生逃逸
stack overflow上有人提问为什么使用指针的chan比使用值的chan慢30%,答案就在这里:使用指针的chan发生逃逸,gc拖慢了速度。

总结

我们得出了指针必然发生逃逸的三种情况(go version go1.13.4 darwin/amd64):

  • 在某个函数中new或字面量创建出的变量,将其指针作为函数返回值,则该变量一定发生逃逸(构造函数返回的指针变量一定逃逸);
  • 被已经逃逸的变量引用的指针,一定发生逃逸;
  • 被指针类型的slice、map和chan引用的指针,一定发生逃逸;

标题:Golang逃逸分析
作者:reyren
地址:https://www.reyren.cn/articles/2021/09/09/1631177712570.html

    0 评论
avatar