golang-内存逃逸分析

2021/03/05 golang
原文地址:https://mp.weixin.qq.com/s/DlRxt_3qw2mcNP5TQl1Okw

前言:内存逃逸是什么?为什么会发生内存逃逸?内存逃逸的具体实例?内存逃逸发生在什么时期?内存逃逸对我们编码有什么启示?

1、内存逃逸是什么?为什么会发生内存逃逸?

一句话答题:

为做到把变量尽可能的分配到合理的位置,go程序在编译时会将变量合理的分配到堆和栈上面,从而尽可能的减少分配堆内存的开销,减少GC压力,提高程序运行速度。

必备知识:

  • 栈内存较小,分配内存非常快
  • 堆内存分配效率相对较低,同时堆内存过高会导致频繁的垃圾回收

详细解释版:

C/C++中动态分配的内存需要我们手动释放,导致猿们平时在写程序时,如履薄冰。这样做有他的好处:程序员可以完全掌控内存。但是缺点也是很多的:经常出现忘记释放内存,导致内存泄露。所以,很多现代语言都加上了垃圾回收机制。

Go的垃圾回收,让堆和栈对程序员保持透明。真正解放了程序员的双手,让他们可以专注于业务,“高效”地完成代码编写。把那些内存管理的复杂机制交给编译器,而程序员可以去享受生活。

逃逸分析这种“骚操作”把变量合理地分配到它该去的地方,“找准自己的位置”。即使你是用new申请到的内存,如果我发现你竟然在退出函数后没有用了,那么就把你丢到栈上,毕竟栈上的内存分配比堆上快很多;反之,即使你表面上只是一个普通的变量,但是经过逃逸分析后发现在退出函数之后还有其他地方在引用,那我就把你分配到堆上。真正地做到“按需分配”,提前实现共产主义!

如果变量都分配到堆上,堆不像栈可以自动清理。它会引起Go频繁地进行垃圾回收,而垃圾回收会占用比较大的系统开销(占用CPU容量的25%)。

堆和栈相比,堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片。栈内存分配则会非常快。栈分配内存只需要两个CPU指令:“PUSH”和“RELEASE”,分配和释放;而堆分配内存首先需要去找到一块大小合适的内存块,之后要通过垃圾回收才能释放。

通过逃逸分析,可以尽量把那些不需要分配到堆上的变量直接分配到栈上,堆上的变量少了,会减轻分配堆内存的开销,同时也会减少gc的压力,提高程序的运行速度。

2、逃逸分析是怎么完成的?

Go逃逸分析最基本的原则是:如果一个函数返回对一个变量的引用,那么它就会发生逃逸

任何时候,一个值被分享到函数栈帧范围之外,它都会在堆上被重新分配。

简单来说,编译器会分析代码的特征和代码生命周期,Go中的变量只有在编译器可以证明在函数返回后不会再被引用的,才分配到栈上,其他情况下都是分配到堆上。

Go语言里没有一个关键字或者函数可以直接让变量被编译器分配到堆上,相反,编译器通过分析代码来决定将变量分配到何处。

对一个变量取地址,可能会被分配到堆上。但是编译器进行逃逸分析后,如果考察到在函数返回后,此变量不会被引用,那么还是会被分配到栈上。套个取址符,就想骗补助?Too young!

简单来说,编译器会根据变量是否被外部引用来决定是否逃逸:

  • 如果函数外部没有引用,则优先放到栈中
  • 如果函数外部存在引用,则必定放到堆中

逃逸的常见情况:

  • 在方法内把局部变量指针返回 局部变量原本应该在栈中分配,在栈中回收。但是由于返回时被外部引用,因此其生命周期大于栈,则溢出。
  • 发送指针或带有指针的值到 channel 中。 在编译时,是没有办法知道哪个 goroutine 会在 channel 上接收数据。所以编译器没法知道变量什么时候才会被释放。
  • 在一个切片上存储指针或带指针的值。 一个典型的例子就是 []*string 。这会导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。
  • slice 的背后数组被重新分配了,因为 append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。
  • 在 interface 类型上调用方法。 在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知道。想像一个 io.Reader 类型的变量 r , 调用 r.Read(b) 会使得 r 的值和切片b 的背后存储都逃逸掉,所以会在堆上分配。

3、举个栗子

(1)例子1:局部指针导致逃逸

package main
import "fmt"

type A struct {
     s string
}
// 这是上面提到的 "在方法内把局部变量指针返回" 的情况
func foo(s string) *A {
     a := new(A) 
     a.s = s
     return a //返回局部变量a,在C语言中妥妥野指针,但在go则ok,但a会逃逸到堆
}
func main() {
     a := foo("hello")
     b := a.s + " world"
     c := b + "!"
     fmt.Println(c)
}

执行 go build -gcflags=-m main.go

go build -gcflags=-m main.go
# command-line-arguments
./main.go:7:6: can inline foo
./main.go:13:10: inlining call to foo
./main.go:16:13: inlining call to fmt.Println
/var/folders/45/qx9lfw2s2zzgvhzg3mtzkwzc0000gn/T/go-build409982591/b001/_gomod_.go:6:6: can inline init.0
./main.go:7:10: leaking param: s
./main.go:8:10: new(A) escapes to heap
./main.go:16:13: io.Writer(os.Stdout) escapes to heap
./main.go:16:13: c escapes to heap
./main.go:15:9: b + "!" escapes to heap
./main.go:13:10: main new(A) does not escape
./main.go:14:11: main a.s + " world" does not escape
./main.go:16:13: main []interface {} literal does not escape
<autogenerated>:1: os.(*File).close .this does not escape
  • ./main.go:8:10: new(A) escapes to heap 说明 new(A) 逃逸了,符合上述提到的常见情况中的第一种。
  • ./main.go:14:11: main a.s + " world" does not escape 说明 b 变量没有逃逸,因为它只在方法内存在,会在方法结束时被回收。
  • ./main.go:15:9: b + "!" escapes to heap 说明 c 变量逃逸,通过fmt.Println(a ...interface{})打印的变量,都会发生逃逸,感兴趣的朋友可以去查查为什么。

(2)例子2:fmt.Printf()导致逃逸

再来一个例子,用来说明fmt.Println(a …interface{})/fmt.Printf(),会导致内存逃逸

package main

import "fmt"

func foo(num int){
	fmt.Printf("%d",num)
}

func main() {
	num := 10
	foo(num)
}

查看编译逃逸信息,确实发生了内存逃逸,那为什么会有这种现象的产生呢?

# command-line-arguments
.\main.go:5:6: can inline foo
.\main.go:6:13: inlining call to fmt.Println
.\main.go:10:6: can inline main
.\main.go:12:5: inlining call to foo
.\main.go:12:5: inlining call to fmt.Println
C:\Users\THUNDER\AppData\Local\Temp\go-build970928790\b001\_gomod_.go:6:6: can inline init.0
.\main.go:6:13: num escapes to heap
.\main.go:6:13: io.Writer(os.Stdout) escapes to heap
.\main.go:6:13: foo []interface {} literal does not escape
.\main.go:12:5: num escapes to heap
.\main.go:12:5: io.Writer(os.Stdout) escapes to heap
.\main.go:12:5: main []interface {} literal does not escape
<autogenerated>:1: os.(*File).close .this does not escape
<autogenerated>:1: os.(*File).isdir .this does not escape

其实,fmt.Printf 第二个参数,是一个 interface 类型。而 fmt.Printf 的内部实现,使用了反射 reflect,正是由于 reflect 才导致变量从栈向堆内存的逃逸成为可能(注意,并非所有reflect操作都会导致内存逃逸,具体还得看怎么使用reflect的)。我们简单总结为:

使用 fmt.Printf 由于其函数第二个参数是接口类型,而函数内部最终实现使用了 reflect 机制,导致变量从栈逃逸到堆内存。

(3)例子3:空struct内存逃逸问题

原文地址:一个奇怪的golang等值判断问题

package main

import (
    "fmt"
    "runtime"
)

type obj struct{}

func main() {
    a := &obj{}
    fmt.Printf("%p\n", a)
    c := &obj{}
    fmt.Printf("%p\n", c)
    fmt.Println(a == c)
}

很多人可能一看,a和c完全是2个不同的对象实例,便认为a和c具备不同的内存地址,故而判断a==c的结果为false。我也是一样。我们看一下实际输出:

0x1181f88
0x1181f88
true

问题分析

要分析上面的问题,就需要了解一些Golang内存分配,以及变量在内存逃逸的知识。上面的代码,有打印a和c的内存地址。倘若我们去掉任意一个(或者将打印内存的地址都去掉也一样),则 a==c 的判断输出,就是 false。再看一下代码:

package main

import (
    "fmt"
)

type obj struct{}

func main() {
    a := &obj{}
    //fmt.Printf("%p\n", a)
    c := &obj{}
    fmt.Printf("%p\n", c)
    fmt.Println(a == c)
}

输出:

0x1181f88
false

确实是内存逃逸导致了该问题

为什么变量 a 和 c 逃逸到堆内存后,内存地址就一样了?

这是因为,堆上内存分配调用了 runtime 包的 newobject 函数。而 newobject 函数其实本质上会调用 runtime 包内的 mallocgc 函数。这个函数有点特别:

// Allocate an object of size bytes.
// Small objects are allocated from the per-P cache's free lists.
// Large objects (> 32 kB) are allocated straight from the heap.
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
	if gcphase == _GCmarktermination {
		throw("mallocgc called with gcphase == _GCmarktermination")
	}

  // 关键部分,如果要分配内存的变量不占用实际内存,则直接用 golang 的全局变量 zerobase 的地址。
	if size == 0 {
		return unsafe.Pointer(&zerobase)
	}

  // ...
}

函数比较长,我做了截取。这函数内有一个判断。 如果要分配内存的变量不占用实际内存,则直接用 golang 的全局变量 zerobase 的地址。而我们的变量 a 和 变量 c 有一个共同特点,就是它们是“空 struct”,空 struct 是不占用内存空间的。

所以,a 和 c 是空 struct,再做内存分配的时候,使用了 golang 内部全局私有变量 zerobase 的内存地址。

如何验证 a 和 c 都使用的是 runtime包内的 zerobase 内存地址?

改一下 runtime 包中,mallocgc 函数所在的文件 runtime/malloc.go 增加一个函数 GetZeroBasePtr ,这个函数,专门用于返回 zerobase 的地址,如下:

// base address for all 0-byte allocations
var zerobase uintptr

func GetZeroBasePtr() unsafe.Pointer {
    return unsafe.Pointer(&zerobase)
}

好了,我们回过头再改一下测试代码:

package main

import (
    "fmt"
    "runtime"
)

type obj struct{}

func main() {
    a := &obj{}
    // 打印 a 的地址
    fmt.Printf("%p\n", a)
    c := &obj{}
    // 打印 c 的地址
    fmt.Printf("%p\n", c)
    fmt.Println(a == c)
    // 打印 runtime 包内的 zerobase 的地址
    ptr := runtime.GetZeroBasePtr()
    fmt.Printf("golang inner zerobase ptr: %p\n", ptr)
}

重新编译:

// 注意,改了 golang 的源码,再编译的话,必须加 -a 参数
go build -a

结果输出如下:

0x1181f88
0x1181f88
true
golang inner zerobase ptr: 0x1181f88

问题得证

总结

  • 堆上动态分配内存比栈上静态分配内存,开销大很多。
  • 变量分配在栈上需要能在编译期确定它的作用域,否则会分配到堆上
  • Go编译器会在编译期对考察变量的作用域,并作一系列检查,如果它的作用域在运行期间对编译器一直是可知的,那么就会分配到栈上。简单来说,编译器会根据变量是否被外部引用来决定是否逃逸。
  • 对于Go程序员来说,编译器的这些逃逸分析规则不需要掌握,我们只需通过go build -gcflags ‘-m’命令来观察变量逃逸情况就行了。
  • 不要盲目使用变量的指针作为函数参数,虽然它会减少复制操作,但其实当参数为变量自身的时候,复制是在栈上完成的操作,开销远比变量逃逸后动态地在堆上分配内存少的多。
  • 最后,尽量写出少一些逃逸的代码,提升程序的运行效率。

参考资料


公众号:豆仔gogo

golang、算法、后端知识、面试手册

豆仔gogo

Search

    公众号:豆仔gogo

    豆仔gogo

    Post Directory