Go 逃逸分析

·  阅读 470

堆内存与栈内存

Go 程序会在 2 个地方为变量分配内存,一个是全局的堆(heap)空间用来动态分配内存,另一个是每个 goroutine 的栈(stack)空间。与 Java、Python 等语言类似,Go 语言实现垃圾回收(Garbage Collector)机制,因此呢,Go 语言的内存管理是自动的,通常开发者并不需要关心内存分配在栈上,还是堆上。但是从性能的角度出发,在栈上分配内存和在堆上分配内存,性能差异是非常大的。

在函数中申请一个对象,如果分配在栈中,函数执行结束时自动回收,如果分配在堆中,则在函数结束后某个时间点进行垃圾回收。

在栈上分配和回收内存的开销很低,只需要 2 个 CPU 指令:PUSHPOP,一个是将数据 push 到栈空间以完成分配,pop 则是释放空间,也就是说在栈上分配内存,消耗的仅是将数据拷贝到内存的时间,而内存的 I/O 通常能够达到 30GB/s,因此在栈上分配内存效率是非常高的。

在堆上分配内存,一个很大的额外开销则是垃圾回收。Go 语言使用的是标记清除算法,并且在此基础上使用了三色标记法和写屏障技术,提高了效率。

标记清除收集器是跟踪式垃圾收集器,其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段:

  • 标记阶段 — 从根对象出发查找并标记堆中所有存活的对象;
  • 清除阶段 — 遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表。

标记清除算法的一个典型耗时是在标记期间,需要暂停程序(Stop the world,STW),标记结束之后,用户程序才可以继续执行。

— Go 垃圾回收机制

堆内存分配导致垃圾回收的开销远远大于栈空间分配与释放的开销。

逃逸分析

什么是逃逸分析

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

From a correctness standpoint, you don't need to know. 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 recognizes some cases when such variables will not live past the return from the function and can reside on the stack.

在Go里面定义了一个变量,到底是分配在堆上还是栈上,Go官方文档告诉我们,不需要管,他们会分析,其实这个分析就是逃逸分析。 在编程语言的编译优化原理中,分析指针动态范围的方法称之为逃逸分析通俗来讲,当一个对象的指针被多个方法或线程引用时,我们称这个指针发生了逃逸。

发生逃逸行为的情况主要有两种:

  • 方法逃逸:当一个对象在方法中定义之后,作为参数传递或返回值到其它方法中

  • 线程逃逸:如类变量或实例变量,可能被其它线程访问到

通过逃逸分析来判断一个变量到底是分配在堆上还是栈上

指针逃逸

指针逃逸应该是最容易理解的一种情况了,即在函数中创建了一个对象,返回了这个对象的指针。这种情况下,函数虽然退出了,但是因为指针的存在,对象的内存不能随着函数结束而回收,因此只能分配在堆上。

// main_pointer.go
package main

import "fmt"

type Demo struct {
	name string
}

func createDemo(name string) *Demo {
	d := new(Demo) // 局部变量 d 逃逸到堆
	d.name = name
	return d
}

func main() {
	demo := createDemo("demo")
	fmt.Println(demo)
}
复制代码

这个例子中,函数 createDemo 的局部变量 d 发生了逃逸。d 作为返回值,在 main 函数中继续使用,因此 d 指向的内存不能够分配在栈上,随着函数结束而回收,只能分配在堆上。

编译时可以借助选项 -gcflags=-m,查看变量逃逸的情况:

    $ go build -gcflags=-m main_pointer.go 
    ./main_pointer.go:10:6: can inline createDemo
    ./main_pointer.go:17:20: inlining call to createDemo
    ./main_pointer.go:18:13: inlining call to fmt.Println
    ./main_pointer.go:10:17: leaking param: name
    ./main_pointer.go:11:10: new(Demo) escapes to heap
    ./main_pointer.go:17:20: new(Demo) escapes to heap
    ./main_pointer.go:18:13: demo escapes to heap
    ./main_pointer.go:18:13: main []interface {} literal does not escape
    ./main_pointer.go:18:13: io.Writer(os.Stdout) escapes to heap
    <autogenerated>:1: (*File).close .this does not escape
复制代码

new(Demo) escapes to heap 即表示 new(Demo) 逃逸到堆上了。

interface{} 动态类型逃逸

在 Go 语言中,空接口即 interface{} 可以表示任意的类型,如果函数参数为 interface{},编译期间很难确定其参数的具体类型,也会发生逃逸。

例如上面例子中的局部变量 demo

func main() {
	demo := createDemo("demo")
	fmt.Println(demo)
}

./main_pointer.go:18:13: demo escapes to heap
复制代码

demo 是 main 函数中的一个局部变量,该变量作为实参传递给 fmt.Println(),但是因为 fmt.Println() 的参数类型定义为 interface{},因此也发生了逃逸。

fmt 包中的 Println 函数的定义如下:

func Println(a ...interface{}) (n int, err error) {
	return Fprintln(os.Stdout, a...)
}
复制代码

如果我们将上面的例子修改为:

func test(demo *Demo) {
	fmt.Println(demo.name)
}

func main() {
	demo := createDemo("demo")
	test(demo)
}
复制代码

这种情况下,局部变量 demo 不会发生逃逸,但是 demo.name 仍旧会逃逸。

 闭包

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。

— 闭包

func Increase() func() int {
	n := 0
	return func() int {
		n++
		return n
	}
}

func main() {
	in := Increase()
	fmt.Println(in()) // 1
	fmt.Println(in()) // 2
}
复制代码

Increase() 返回值是一个闭包函数,该闭包函数访问了外部变量 n,那变量 n 将会一直存在,直到 in 被销毁。很显然,变量 n 占用的内存不能随着函数 Increase() 的退出而回收,因此将会逃逸到堆上。

$ go build -gcflags=-m main_closure.go 
# command-line-arguments
./main_closure.go:6:2: moved to heap: n
复制代码

如何利用逃逸分析提升性能

传值 VS 传指针

传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收(GC)的负担。在对象频繁创建和删除的场景下,传递指针导致的 GC 开销可能会严重影响性能。

一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能。

分类:
后端
标签:
分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改