逃逸分析
堆与栈
在go语言中,变量可以存储在栈或者堆之上。如果变量存储在栈之上,那么当这个栈被清理时,对应的栈内的变量也随之清理。如果变量存储在堆上,那么就需要GC来清理这个变量
逃逸机制
任何时候,一个值被分享到函数栈帧范围之外,它都会在堆上被重新分配。这是逃逸分析算法发现这些情况和管控这一层的工作。(内存的)完整性在于确保对任何值的访问始终是准确、一致和高效的。
package main
type user struct {
name string
email string
}
func main() {
u1 := createUserV1()
u2 := createUserV2()
println("u1", &u1, "u2", &u2)
}
func createUserV1() user {
u := user{
name: "Bill",
email: "bill@ardanlabs.com",
}
println("V1", &u)
return u
}
func createUserV2() *user {
u := user{
name: "Bill",
email: "bill@ardanlabs.com",
}
println("V2", &u)
return &u
}
上面这一段程序可以看到创建 user 值,并返回给调用者的两个不同的函数。在v1中返回值。
16 func createUserV1() user {
17 u := user{
18 name: "Bill",
19 email: "bill@ardanlabs.com",
20 }
21
22 println("V1", &u)
23 return u
24 }
这个函数返回的是值是因为这个被函数创建的 user 值被拷贝并传递到调用栈上。这意味着调用函数接收到的是这个值的拷贝。
你可以看下第 17 行到 20 行 user 值被构造的过程。然后在第 23 行,user 值的副本被传递到调用栈并返回给调用者。函数返回后,栈看起来如下所示:
可以看到图 1 中,当调用完 createUserV1 ,一个 user 值同时存在(两个函数的)栈帧中。而在v2 中,返回得时user的指针。
27 func createUserV2() *user {
28 u := user{
29 name: "Bill",
30 email: "bill@ardanlabs.com",
31 }
32
33 println("V2", &u)
34 return &u
35 }
这个函数返回的是指针是因为这个被函数创建的 user 值通过调用栈被共享了。这意味着调用函数接收到一个值的地址拷贝。(所以go语言中不存在引用传递,slice,map,channel等等都是因为内部带有共享底层数据结构的指针)
你可以看到在第 28 行到 31 行使用相同的字段值来构造 user 值,但在第 34 行返回时却是不同的。不是将 user 值的副本传递到调用栈,而是将 user 值的地址传递到调用栈。基于此,你也许会认为栈在调用之后是这个样子。
如果看到的图 2 真的发生的话,你将遇到一个问题。指针指向了栈下的无效地址空间。当 main 函数调用下一个函数,指向的内存将重新映射并将被重新初始化。
这就是逃逸分析将开始保持完整性的地方。在这种情况下,编译器将检查到,在 createUserV2 的(函数)栈中构造 user 值是不安全的,因此,替代地,会在堆中构造相应的值。这个分析并处理的过程将在第 28 行构造时立即发生。
我们知道一个函数只能直接访问它的(函数栈)空间,或者通过(函数栈空间内的)指针,通过跳转访问(函数栈空间外的)外部内存。这意味着访问逃逸到堆上的值也需要通过指针跳转。
记住 createUserV2 的代码的样子:
27 func createUserV2() *user {
28 u := user{
29 name: "Bill",
30 email: "bill@ardanlabs.com",
31 }
32
33 println("V2", &u)
34 return &u
35 }
语法隐藏了代码中真正发生的事情。第 28 行声明的变量 u 代表一个 user 类型的值。Go 代码中的类型构造不会告诉你值在内存中的位置。所以直到第 34 行返回类型时,你才知道值需要逃逸(处理)。这意味着,虽然 u 代表类型 user 的一个值,但对该值的访问必须通过指针进行。
你可以在函数调用之后,看到堆栈就像(图 3)这样。
在 createUserV2 函数栈中,变量 u 代表的值存在于堆中,而不是栈。这意味着用 u 访问值时,使用指针访问而不是直接访问。
为什么不让 u 成为指针,毕竟访问它代表的值需要使用指针?
27 func createUserV2() *user {
28 u := &user{
29 name: "Bill",
30 email: "bill@ardanlabs.com",
31 }
32
33 println("V2", u)
34 return u
35 }
retuen u
告诉你什么了呢?它说明了返回 u 值的副本给调用栈。然而,当你使用 & 操作符,return 又告诉你什么了呢?
return &u
多亏了 & 操作符,return 告诉你 u 被分享给调用者,因此,已经逃逸到堆中。记住,当你读代码的时候,指针是为了共享,& 操作符对应单词 "sharing"。这在提高可读性的时候非常有用。
编译器逃逸分析
想查看编译器(关于逃逸分析)的决定,你可以让编译器提供一份报告。你只需要在调用 go build 的时候,打开 -gcflags 开关,并带上 -m 选项。
实际上总共可以使用 4 个 -m,(但)超过 2 个级别的信息就已经太多了。我将使用 2 个 -m 的级别。
D:\GoProject\GoStudy\mem>go build -gcflags "-m -m"
# _/D_/GoProject/GoStudy/mem
.\demo1.go:15:6: can inline createUserV1 as: func() user { u := user literal; println("V1", &u); return u }
.\demo1.go:25:6: can inline createUserV2 as: func() *user { u := user literal; println("V2", &u); return &u }
.\demo1.go:8:6: can inline main as: func() { u1 := createUserV1(); u2 := createUserV2(); println("u1", &u1, "u2", &u2) }
.\demo1.go:9:20: inlining call to createUserV1 func() user { u := user literal; println("V1", &u); return u }
.\demo1.go:10:20: inlining call to createUserV2 func() *user { u := user literal; println("V2", &u); return &u }
.\demo1.go:26:2: u escapes to heap:
.\demo1.go:26:2: flow: ~r0 = &u:
.\demo1.go:26:2: from &u (address-of) at .\demo1.go:32:9
.\demo1.go:26:2: from return &u (return) at .\demo1.go:32:2
.\demo1.go:26:2: moved to heap: u
.\demo1.go:26:2: u escapes to heap:
.\demo1.go:26:2: flow: ~r0 = &u:
.\demo1.go:26:2: from &u (address-of) at .\demo1.go:32:9
.\demo1.go:26:2: from return &u (return) at .\demo1.go:32:2
.\demo1.go:26:2: moved to heap: u
这几行是说,类型为 user,并在第 26行被赋值的 u 的值,因为第 32 行的 return 逃逸。
总结
值在构建时并不能决定它将存在于哪里。只有当一个值被共享,编译器才能决定如何处理这个值。当你在调用时,共享了栈上的一个值时,它就会逃逸。
每种方式都有(对应的)好处和(额外的)开销。保持在栈上的值,减少了 GC 的压力。但是需要存储,跟踪和维护不同的副本。将值放在堆上的指针,会增加 GC 的压力。然而,也有它的好处,只有一个值需要存储,跟踪和维护。(其实,)最关键的是如何保持正确地、一致地以及均衡(开销)地使用。
附:Go中常见的逃逸类型
指针逃逸
典型的逃逸case,函数返回局部变量的指针。
栈空间不足逃逸
当对象大小超过的栈帧大小时,变量对象发生逃逸被分配到堆上。
闭包引用逃逸
动态类型逃逸
当对象不确定大小或者被作为不确定大小的参数时发生逃逸。(常见于interface,动态分配slice大小)
切片或map赋值
在给切片或者map赋值对象指针(与对象共享内存地址时),对象会逃逸到堆上。但赋值对象值或者返回对象值切片是不会发生逃逸的。