小松子来聊一聊go语言的内存逃逸

时间:2021-10-31 20:16:07

小松子来聊一聊go语言的内存逃逸

前言

哈喽,大家好,我是asong。最近无聊看了一下Go语言的面试八股文,发现面试官都喜欢问内存逃逸这个话题,这个激起了我的兴趣,我对内存逃逸的了解很浅,所以找了很多文章精读了一下,在这里做一个总结,方便日后查阅、学习。

什么是内存逃逸

初次看到这个话题,我是懵逼的,怎么还有内存逃逸,内存逃逸到底是干什么的?接下来我们一起来看看什么是内存逃逸。

我们都知道一般情况下程序存放在rom或者Flash中,运行时需要拷贝到内存中执行,内存会分别存储不同的信息,内存空间包含两个最重要的区域:堆区(Stack)和栈区(Heap),对于我这种C语言出身的人,对堆内存和栈内存的了解还是挺深的。在C语言中,栈区域会专门存放函数的参数、局部变量等,栈的地址从内存高地址往低地址增长,而堆内存正好相反,堆地址从内存低地址往高地址增长,但是如果我们想在堆区域分配内存需要我们手动调用malloc函数去堆区域申请内存分配,然后我使用完了还需要自己手动释放,如果没有释放就会导致内存泄漏。写过C语言的朋友应该都知道C语言函数是不能返回局部变量地址(特指存放于栈区的局部变量地址),除非是局部静态变量地址,字符串常量地址、动态分配地址。其原因是一般局部变量的作用域只在函数内,其存储位置在栈区中,当程序调用完函数后,局部变量会随此函数一起被释放。其地址指向的内容不明(原先的数值可能不变,也可能改变)。而局部静态变量地址和字符串常量地址存放在数据区,动态分配地址存放在堆区,函数运行结束后只会释放栈区的内容,而不会改变数据区和堆区。

所以在C语言中我们想在一个函数中返回局部变量地址时,有三个正确的方式:返回静态局部变量地址、返回字符串常量地址,返回动态分配在堆上的地址,因为他们都不在栈区,即使释放函数,其内容也不会受影响,我们以在返回堆上内存地址为例看一段代码:

  1. #include "stdio.h" 
  2. #include "stdlib.h" 
  3. //返回动态分配的地址  
  4. int* f1() 
  5.     int a = 9; 
  6.     int *pa = (int*) malloc(8); 
  7.     *pa = a; 
  8.     return pa; 
  9.  
  10. int main() 
  11.     int *pb; 
  12.     pb = f1(); 
  13.     printf("after : *pb = %d\tpb = %p\n",*pb, pb); 
  14.     free(pb); 
  15.     return 1; 

通过上面的例子我们知道在C语言中动态内存的分配与释放完全交与程序员的手中,这样就会导致我们在写程序时如履薄冰,好处是我们可以完全掌控内存,缺点是我们一不小心就会导致内存泄漏,所以很多现代语言都有GC机制,Go就是一门带垃圾回收的语言,真正解放了我们程序员的双手,我们不需要在像写C语言那样考虑是否能返回局部变量地址了,内存管理交与给编译器,编译器会经过逃逸分析把变量合理的分配到"正确"的地方。

说到这里,可以简单总结一下什么是内存逃逸了:

在一段程序中,每一个函数都会有自己的内存区域存放自己的局部变量、返回地址等,这些内存会由编译器在栈中进行分配,每一个函数都会分配一个栈桢,在函数运行结束后进行销毁,但是有些变量我们想在函数运行结束后仍然使用它,那么就需要把这个变量在堆上分配,这种从"栈"上逃逸到"堆"上的现象就成为内存逃逸。

什么是逃逸分析

上面我们知道了什么是内存逃逸,下面我们就来看一看什么是逃逸分析?

上文我们说到C语言使用malloc在堆上动态分配内存后,还需要手动调用free释放内存,如果不释放就会造成内存泄漏的风险。在Go语言中堆内存的分配与释放完全不需要我们去管了,Go语言引入了GC机制,GC机制会对位于堆上的对象进行自动管理,当某个对象不可达时(即没有其对象引用它时),他将会被回收并被重用。虽然引入GC可以让开发人员降低对内存管理的心智负担,但是GC也会给程序带来性能损耗,当堆内存中有大量待扫描的堆内存对象时,将会给GC带来过大的压力,虽然Go语言使用的是标记清除算法,并且在此基础上使用了三色标记法和写屏障技术,提高了效率,但是如果我们的程序仍在堆上分配了大量内存,依赖会对GC造成不可忽视的压力。因此为了减少GC造成的压力,Go语言引入了逃逸分析,也就是想法设法尽量减少在堆上的内存分配,可以在栈中分配的变量尽量留在栈中。

小结逃逸分析:

逃逸分析就是指程序在编译阶段根据代码中的数据流,对代码中哪些变量需要在栈中分配,哪些变量需要在堆上分配进行静态分析的方法。堆和栈相比,堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片。栈内存分配则会非常快。栈分配内存只需要两个CPU指令:“PUSH”和“RELEASE”,分配和释放;而堆分配内存首先需要去找到一块大小合适的内存块,之后要通过垃圾回收才能释放。所以逃逸分析更做到更好内存分配,提高程序的运行速度。

Go语言中的逃逸分析

Go语言的逃逸分析总共实现了两个版本:

  • 1.13版本前是第一版
  • 1.13版本后是第二版

粗略看了一下逃逸分析的代码,大概有1500+行(go1.15.7)。代码我倒是没仔细看,注释我倒是仔细看了一遍,注释写的还是很详细的,代码路径:src/cmd/compile/internal/gc/escape.go,大家可以自己看一遍注释,其逃逸分析原理如下:

  • pointers to stack objects cannot be stored in the heap:指向栈对象的指针不能存储在堆中
  • pointers to a stack object cannot outlive that object:指向栈对象的指针不能超过该对象的存活期,也就说指针不能在栈对象被销毁后依旧存活。(例子:声明的函数返回并销毁了对象的栈帧,或者它在循环迭代中被重复用于逻辑上不同的变量)

我们大概知道它的分析准则是什么就好了,具体逃逸分析是怎么做的,感兴趣的同学可以根据源码自行研究。

既然逃逸分析是在编译阶段进行的,那我们就可以通过go build -gcflags '-m -m -l'命令查看到逃逸分析的结果,我们之前在分析内联优化时使用的-gcflags '-m -m',能看到所有的编译器优化,这里使用-l禁用掉内联优化,只关注逃逸优化就好了。

现在我们也知道了逃逸分析,接下来我们就看几个逃逸分析的例子。

几个逃逸分析的例子

1. 函数返回局部指针变量

先看例子:

  1. #include "stdio.h" 
  2. #include "stdlib.h" 
  3. //返回动态分配的地址  
  4. int* f1() 
  5.     int a = 9; 
  6.     int *pa = (int*) malloc(8); 
  7.     *pa = a; 
  8.     return pa; 
  9.  
  10. int main() 
  11.     int *pb; 
  12.     pb = f1(); 
  13.     printf("after : *pb = %d\tpb = %p\n",*pb, pb); 
  14.     free(pb); 
  15.     return 1; 

查看逃逸分析结果:

  1. go build -gcflags="-m -m -l" ./test1.go 
  2. # command-line-arguments 
  3. ./test1.go:6:9: &res escapes to heap 
  4. ./test1.go:6:9:         from ~r2 (returnat ./test1.go:6:2 
  5. ./test1.go:4:2: moved to heap: res 

分析结果很明了,函数返回的局部变量是一个指针变量,当函数Add执行结束后,对应的栈桢就会被销毁,但是引用已经返回到函数之外,如果我们在外部解引用地址,就会导致程序访问非法内存,就像上面的C语言的例子一样,所以编译器经过逃逸分析后将其在堆上分配内存。

2. interface类型逃逸

先看一个例子:

  1. func main()  { 
  2.  str := "asong太帅了吧" 
  3.  fmt.Printf("%v",str) 

查看逃逸分析结果:

  1. go build -gcflags="-m -m -l" ./test2.go  
  2. # command-line-arguments 
  3. ./test2.go:9:13: str escapes to heap 
  4. ./test2.go:9:13:        from ... argument (arg to ...) at ./test2.go:9:13 
  5. ./test2.go:9:13:        from *(... argument) (indirection) at ./test2.go:9:13 
  6. ./test2.go:9:13:        from ... argument (passed to call[argument content escapes]) at ./test2.go:9:13 
  7. ./test2.go:9:13: main ... argument does not escape 

str是main函数中的一个局部变量,传递给fmt.Println()函数后发生了逃逸,这是因为fmt.Println()函数的入参是一个interface{}类型,如果函数参数为interface{},那么在编译期间就很难确定其参数的具体类型,也会发送逃逸。

观察这个分析结果,我们可以看到没有moved to heap: str,这也就是说明str变量并没有在堆上进行分配,只是它存储的值逃逸到堆上了,也就说任何被str引用的对象必须分配在堆上。如果我们把代码改成这样:

  1. func main()  { 
  2.  str := "asong太帅了吧" 
  3.  fmt.Printf("%p",&str) 

查看逃逸分析结果:

  1. go build -gcflags="-m -m -l" ./test2.go 
  2. # command-line-arguments 
  3. ./test2.go:9:18: &str escapes to heap 
  4. ./test2.go:9:18:        from ... argument (arg to ...) at ./test2.go:9:12 
  5. ./test2.go:9:18:        from *(... argument) (indirection) at ./test2.go:9:12 
  6. ./test2.go:9:18:        from ... argument (passed to call[argument content escapes]) at ./test2.go:9:12 
  7. ./test2.go:9:18: &str escapes to heap 
  8. ./test2.go:9:18:        from &str (interface-converted) at ./test2.go:9:18 
  9. ./test2.go:9:18:        from ... argument (arg to ...) at ./test2.go:9:12 
  10. ./test2.go:9:18:        from *(... argument) (indirection) at ./test2.go:9:12 
  11. ./test2.go:9:18:        from ... argument (passed to call[argument content escapes]) at ./test2.go:9:12 
  12. ./test2.go:8:2: moved to heap: str 
  13. ./test2.go:9:12: main ... argument does not escape 

这回str也逃逸到了堆上,在堆上进行内存分配,这是因为我们访问str的地址,因为入参是interface类型,所以变量str的地址以实参的形式传入fmt.Printf后被装箱到一个interface{}形参变量中,装箱的形参变量的值要在堆上分配,但是还要存储一个栈上的地址,也就是str的地址,堆上的对象不能存储一个栈上的地址,所以str也逃逸到堆上,在堆上分配内存。(这里注意一个知识点:Go语言的参数传递只有值传递)

3. 闭包产生的逃逸

  1. func Increase() func() int { 
  2.  n := 0 
  3.  return func() int { 
  4.   n++ 
  5.   return n 
  6.  } 
  7.  
  8. func main() { 
  9.  in := Increase() 
  10.  fmt.Println(in()) // 1 

查看逃逸分析结果:

  1. go build -gcflags="-m -m -l" ./test3.go 
  2. # command-line-arguments 
  3. ./test3.go:10:3: Increase.func1 capturing by ref: n (addr=true assign=true width=8) 
  4. ./test3.go:9:9: func literal escapes to heap 
  5. ./test3.go:9:9:         from ~r0 (assigned) at ./test3.go:7:17 
  6. ./test3.go:9:9: func literal escapes to heap 
  7. ./test3.go:9:9:         from &(func literal) (address-ofat ./test3.go:9:9 
  8. ./test3.go:9:9:         from ~r0 (assigned) at ./test3.go:7:17 
  9. ./test3.go:10:3: &n escapes to heap 
  10. ./test3.go:10:3:        from func literal (captured by a closure) at ./test3.go:9:9 
  11. ./test3.go:10:3:        from &(func literal) (address-ofat ./test3.go:9:9 
  12. ./test3.go:10:3:        from ~r0 (assigned) at ./test3.go:7:17 
  13. ./test3.go:8:2: moved to heap: n 
  14. ./test3.go:17:16: in() escapes to heap 
  15. ./test3.go:17:16:       from ... argument (arg to ...) at ./test3.go:17:13 
  16. ./test3.go:17:16:       from *(... argument) (indirection) at ./test3.go:17:13 
  17. ./test3.go:17:16:       from ... argument (passed to call[argument content escapes]) at ./test3.go:17:13 
  18. ./test3.go:17:13: main ... argument does not escape 

因为函数也是一个指针类型,所以匿名函数当作返回值时也发生了逃逸,在匿名函数中使用外部变量n,这个变量n会一直存在直到in被销毁,所以n变量逃逸到了堆上。

4. 变量大小不确定及栈空间不足引发逃逸

我们先使用ulimit -a查看操作系统的栈空间:

  1. ulimit -a 
  2. -t: cpu time (seconds)              unlimited 
  3. -f: file size (blocks)              unlimited 
  4. -d: data seg size (kbytes)          unlimited 
  5. -s: stack size (kbytes)             8192 
  6. -c: core file size (blocks)         0 
  7. -v: address space (kbytes)          unlimited 
  8. -l: locked-in-memory size (kbytes)  unlimited 
  9. -u: processes                       2784 
  10. -n: file descriptors                256 

我的电脑的栈空间大小是8192,所以根据这个我们写一个测试用例:

  1. package main 
  2.  
  3. import ( 
  4.  "math/rand" 
  5.  
  6. func LessThan8192()  { 
  7.  nums := make([]int, 100) // = 64KB 
  8.  for i := 0; i < len(nums); i++ { 
  9.   nums[i] = rand.Int() 
  10.  } 
  11.  
  12.  
  13. func MoreThan8192(){ 
  14.  nums := make([]int, 1000000) // = 64KB 
  15.  for i := 0; i < len(nums); i++ { 
  16.   nums[i] = rand.Int() 
  17.  } 
  18.  
  19.  
  20. func NonConstant() { 
  21.  number := 10 
  22.  s := make([]int, number) 
  23.  for i := 0; i < len(s); i++ { 
  24.   s[i] = i 
  25.  } 
  26.  
  27. func main() { 
  28.  NonConstant() 
  29.  MoreThan8192() 
  30.  LessThan8192() 

查看逃逸分析结果:

  1. go build -gcflags="-m -m -l" ./test4.go 
  2. # command-line-arguments 
  3. ./test4.go:8:14: LessThan8192 make([]int, 100) does not escape 
  4. ./test4.go:16:14: make([]int, 1000000) escapes to heap 
  5. ./test4.go:16:14:       from make([]int, 1000000) (non-constant sizeat ./test4.go:16:14 
  6. ./test4.go:25:11: make([]int, number) escapes to heap 
  7. ./test4.go:25:11:       from make([]int, number) (non-constant sizeat ./test4.go:25:11 

我们可以看到,当栈空间足够时,不会发生逃逸,但是当变量过大时,已经完全超过栈空间的大小时,将会发生逃逸到堆上分配内存。

同样当我们初始化切片时,没有直接指定大小,而是填入的变量,这种情况为了保证内存的安全,编译器也会触发逃逸,在堆上进行分配内存。

参考文章(建议大家阅读一遍)

  • https://driverzhang.github.io/post/golang%E5%86%85%E5%AD%98%E5%88%86%E9%85%8D%E9%80%83%E9%80%B8%E5%88%86%E6%9E%90/
  • https://segmentfault.com/a/1190000039843497
  • https://tonybai.com/2021/05/24/understand-go-escape-analysis-by-example/
  • https://cloud.tencent.com/developer/article/1732263
  • https://geektutu.com/post/hpg-escape-analysis.html

总结

本文到这里结束了,这篇文章我们一起分析了什么是内存逃逸以及Go语言中的逃逸分析,上面只列举了几个例子,因为发生的逃逸的情况是列举不全的,我们只需要了解什么是逃逸分析,了解逃逸的策略就可以了,后面在实战中可以根据具体代码具体分析,写出更优质的代码。

最后对逃逸做一个总结:

  • 逃逸分析在编译阶段确定哪些变量可以分配在栈中,哪些变量分配在堆上
  • 逃逸分析减轻了GC压力,提高程序的运行速度
  • 栈上内存使用完毕不需要GC处理,堆上内存使用完毕会交给GC处理
  • 函数传参时对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能
  • 根据代码具体分析,尽量减少逃逸代码,减轻GC压力,提高性能

原文链接:https://mp.weixin.qq.com/s/MepbrrSlGVhNrEkTQhfhhQ