查看原文
其他

Go逃逸分析从入门到精通

Go开发大全 2021-01-31

(给Go开发大全加星标)

英文:William Kennedy,翻译:Ryan Lu

https://www.ardanlabs.com/blog/2017/05/language-mechanics-on-escape-analysis.html

【导读】几周前面试被问到了golang内存逃逸和如何做逃逸分析。在实际golang项目开发中逃逸分析是做性能优化和编写代码时需要注意的点。这篇文章的重点是go语言的堆和逃逸分析


介绍


昨天推送的文章中,通过使用一个示例来分析GO指针机制的基础知识,其中一个值在goroutine的堆栈中共享。我没有告诉你的是当你在堆栈中共享一个值时会发生什么。要理解这一点,需要了解值可以存在的另一个内存区域:“堆”。有了这些知识,就可以开始学习“逃逸分析”了。


逃逸分析是编译器用于确定程序创建的值的位置的过程。具体来说,编译器执行静态代码分析以确定是否可以在构造它的函数的堆栈帧上放置一个值,或者该值是否必须“转义”到堆。在Go中,没有关键字或函数可用于指导编译器做出此决定。只有通过你编写代码来决定这个决定的惯例。



除了堆栈之外,堆是存储器的第二个存储区域,用于存储值。堆不像堆栈那样自我清理,因此使用此内存的成本更高。主要是,成本与垃圾收集器(GC)相关,垃圾收集器必须参与以保持该区域清洁。GC运行时,它将使用25%的可用CPU容量。此外,它可能会创造微秒级的stop the world延迟。拥有GC的好处是您无需担心管理堆内存,这在历史上一直很复杂且容易出错。


堆上的值构成Go中的内存分配。这些分配对GC施加了压力,因为需要删除不再由指针引用的堆上的每个值。需要检查和删除的值越多,GC在每次运行时必须执行的工作量就越多。因此,调步算法一直在努力平衡堆的大小和运行的速度。


共享堆栈


在Go中,不允许goroutine有一个指针指向另一个goroutine堆栈上的内存。这是因为当堆栈必须增长或缩小时,goroutine的堆栈内存可以用新的内存块替换。如果runtime必须跟踪指向其他goroutine堆栈的指针,那么runtime需要处理的工作将变得太复杂,而且更新那些堆栈上的指针时stop the world延迟将会明显增加。


以下是由于堆栈增长而多次改变堆栈地址空间的堆栈示例。

// main.go// Number of elements to grow each stack frame.// Run with 10 and then with 1024const size = 1024
// main is the entry point for the application.func main() { s := "HELLO" stackCopy(&s, 0, [size]int{})}
// stackCopy recursively runs increasing the size// of the stack.func stackCopy(s *string, c int, a [size]int) { println(c, s, *s)
c++ if c == 10 { return }
stackCopy(s, c, a)}

查看第2行和第6行的输可以看到Main Stack Frame内的字符串值的地址发生了两次变化。

0 0x44dfa8 HELLO1 0x44dfa8 HELLO2 0x455fa8 HELLO3 0x455fa8 HELLO4 0x455fa8 HELLO5 0x455fa8 HELLO6 0x465fa8 HELLO7 0x465fa8 HELLO8 0x465fa8 HELLO9 0x465fa8 HELLO


逃逸机制


只要在函数的Stack Frame范围之外共享一个值,它就会被放置(或分配)在堆上。逃逸分析算法的工作是找到这些情况并保持程序中的完整性。完整性在于确保对任何值的访问始终准确,一致和高效。


可以通过这个例子逃逸分析背后的基本机制:  

package main
type user struct { name string email string}
func main() { u1 := createUserV1() u2 := createUserV2()
println("u1", &u1, "u2", &u2)}
//go:noinlinefunc createUserV1() user { u := user{ name: "Bill", email: "bill@ardanlabs.com", }
println("V1", &u) return u}
//go:noinlinefunc createUserV2() *user { u := user{ name: "Bill", email: "bill@ardanlabs.com", }
println("V2", &u) return &u}

我使用go:noinline指令来阻止编译器直接在main中内联这些函数的代码。内联将擦除函数调用并使此示例复杂化。


在前面这段代码中,您将看到一个具有两个不同函数的程序,这些函数创建一个类型为user值并将值返回给调用者。该函数的版本1在返回时使用值语义。这里的值语义是指该函数创建的临时变量u正被复制并传递给调用栈,这意味着调用函数正在接收值本身的副本。

16 func createUserV1() user {17 u := user{18 name: "Bill",19 email: "bill@ardanlabs.com",20 }2122 println("V1", &u)23 return u24 }

可以看到在第17行到第20行执行的用户值的构造。然后在第23行,将用户值的副本向上传递给调用栈并返回给调用者。函数返回后,堆栈看起来像这样:


可以在上图中看到,在调用createUserV1之后,两个Stack Frame中都存在用户值。

在函数的第2版中,返回时使用了指针语义。这里的指针语义是指该函数创建的user值正在被该函数的调用者共享。调用函数正在接收该值的地址副本。

27 func createUserV2() *user {28 u := user{29 name: "Bill",30 email: "bill@ardanlabs.com",31 }3233 println("V2", &u)34 return &u35 }

可以看到在第28到31行使用相同的结构文字来构造用户值,但在第34行,返回是不同的。不是将user值的副本传递回调用堆栈,而是传递user值的地址副本。基于此,你可能会认为在调用之后堆栈看起来像这样:

如果你在上图中看到的内容真的发生了,就会遇到完整性问题。指针指向调用堆栈,进入不再有效的内存。在main的下一个函数调用中,指向的内存将被重新构建并重新初始化。


这里逃避分析要开始发挥作用了。在这种情况下,编译器将判断出在createUserV2的堆栈框架内构造user值是不安全的,因此它将在堆上构造值。这个过程将在第28行构造user值时发生。


代码可读性


昨天推送的文章讲过,函数可以直接访问其Stack Frame内的内存,但访问其Stack Frame外的内存需要间接访问。这意味着访问转义到堆的值也必须通过指针间接完成。


createUserV2的代码:

27 func createUserV2() *user {28 u := user{29 name: "Bill",30 email: "bill@ardanlabs.com",31 }3233 println("V2", &u)34 return &u35 }

代码表面的语法隐藏了其中真正发生的事情。第28行声明的变量u表示user类型的值。Go中的构造并没有告诉你值在内存中的位置,所以直到第34行的返回语句,你知道该值是否需要转义。这意味着,即使u表示user类型的值,也必须通过隐藏的指针访问此user值。


在函数调用之后,可以将堆栈理解成这样:

函数createUserV2的Stack Frame上的变量u表示Heap上的值,而不是Stack。这意味着使用u来访问值,需要指针访问,而不是直观从代码看到的直接访问。


为什么不让u成为指针,因为访问它所代表的值需要使用指针呢?

27 func createUserV2() *user {28 u := &user{29 name: "Bill",30 email: "bill@ardanlabs.com",31 }3233 println("V2", u)34 return u35 }

这样做会损失代码的可读性。先不看整个函数,而仅仅关注return语句。

34 return u35 }

这个return语句是你的副本被传递给调用者。但是,当使用&运算符时,return语句是什么意思?

34 return &u35 }

由于&运算符,return 语句现在告诉你,你正在共享调用堆栈,因此转移到堆。这在可读性方面更强大。


下面是另一个使用指针语义构造值会损害可读性的示例。

01 var u *user02 err := json.Unmarshal([]byte(r), &u)03 return u, err

必须与第02行的json.Unmarshal调用共享指针变量才能使此代码生效。


json.Unmarshal调用将创建用户值并将其地址分配给指针变量。

package main
import ( "encoding/json" "fmt")
type user struct { ID int Name string}
func main() { u, err := retrieveUser(1234) if err != nil { fmt.Println(err) return }
fmt.Printf("%+v\n", *u)}
func retrieveUser(id int) (*user, error) { r, err := getUser(id) if err != nil { return nil, err }
var u *user err = json.Unmarshal([]byte(r), &u) return u, err}
func getUser(id int) (string, error) { response := fmt.Sprintf(`{"id": %d, "name": "sally"}`, id) return response, nil}

这段代码说明了:

  1. 创建u设置为零值的指针。

  2. 与json.Unmarshal函数共享u。

  3. 返回调用者u的副本。


由json.Unmarshal函数创建的u正与调用者共享, 但是从代码来看这并不明显。如果在构造过程中使用值语义时,代码可读性会有什么变化呢?

01 var u user02 err := json.Unmarshal([]byte(r), &u)03 return &u, err

这段代码又说明了什么:

  1. 创建u设置为零值的值。

  2. 与json.Unmarshal函数共享u。

  3. 与调用者共享u。


一切都很清楚。第02行将u值共享到他的调用函数json.Unmarshal,第03行将函数自己Stack Frame中的u值共享给调用者。此共享将导致u值转义。


在构造值时使用值语义,并利用&运算符的可读性来明确如何共享值。


编译器分析报告


要查看编译器所做的决定,可以查看编译器的编译过程。你需要做的就是在go build调用中使用-gcflags开关和-m选项。有4个级别的-m可以使用,但超过2个, 编译器给出的信息就会过多而不好理解。这里使用2级的-m。

16 func createUserV1() user {17 u := user{18 name: "Bill",19 email: "bill@ardanlabs.com",20 }2122 println("V1", &u)23 return u24 }
27 func createUserV2() *user {28 u := user{29 name: "Bill",30 email: "bill@ardanlabs.com",31 }3233 println("V2", &u)34 return &u35 }
$ go build -gcflags "-m -m"./main.go:16: cannot inline createUserV1: marked go:noinline./main.go:27: cannot inline createUserV2: marked go:noinline./main.go:8: cannot inline main: non-leaf function./main.go:22: createUserV1 &u does not escape./main.go:34: &u escapes to heap./main.go:34: from ~r0 (return) at ./main.go:34./main.go:31: moved to heap: u./main.go:33: createUserV2 &u does not escape./main.go:12: main &u1 does not escape./main.go:12: main &u2 does not escape

可以看到编译器正在检查是否需要逃逸。编译器说了什么?从下面这行,编译器说函数createUserV1内部对println的函数调用并没有导致u值逃逸到堆。编译器必须做这个检查,因为它与println函数共享。

./main.go:22: createUserV1 &u does not escape
接下来看一下下面这些行:
./main.go:34: &u escapes to heap./main.go:34: from ~r0 (return) at ./main.go:34./main.go:31: moved to heap: u./main.go:33: createUserV2 &u does not escape

第31行与u变量相关联的user值,由于第34行的返回而逃逸。最后一行说的意思与之前第22相同,第33行的println调用不会导致user值转义。


阅读这些输出可能会令人困惑,并且根据所指的变量类型是named类型还是literal类型, 编译器的输出可能会稍微改变。


如之前的讨论,将u更改为literal类型*用户,而不是之前的named类型user,编译器会给出什么结论呢?

27 func createUserV2() *user {28 u := &user{29 name: "Bill",30 email: "bill@ardanlabs.com",31 }3233 println("V2", u)34 return u35 }
./main.go:30: &user literal escapes to heap./main.go:30: from u (assigned) at ./main.go:28./main.go:30:   from ~r0 (return) at ./main.go:34

现在,编译器称由于第34行的返回,u变量引用的user值(literal类型* user并在第28行分配)正在逃逸。


结论


变量的构造并不决定该变量的内存地址如何分配。只有该值如何被共享才能让编译器决定如何对改变了进行内存分配。无论何时你在调用堆栈中共享一个值,它都会逃逸。值得逃避还有其他原因,将在下一篇文章中探讨。


这篇文章是为任何给定类型选择值或指针语义的指南。每种语义都带来了好处和对应的成本。值语义将值保留在堆栈上,从而降低了GC的压力。但是,必须存储,跟踪和维护任何给定值的不同副本。


指针语义将值放在堆上,这会给GC带来压力。但很有效,因为只需要存储、跟踪和维护一个值。关键是正确、一致和平衡地使用每个语义。


 - EOF -

推荐阅读(点击标题可打开)

1、详解Golang堆栈指针

2、Go 单元测试:如何使用 Gomock

3、Golang微服务的熔断与限流

如果觉得本文不错,欢迎转发推荐给更多人。

分享、点赞和在看

支持我们分享更多好文章,谢谢!

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存