逃逸分析的语言机制
楔子
这是一个由四部分组成的系列文章的第二篇,该系列文章将提供对Go中指针,栈,堆,逃逸分析和值/指针语法背后设计和机制的理解。这篇文章主要关注堆栈和指针。
四部分系列文章索引:
- Language Mechanics On Stacks And Pointers
- Language Mechanics On Escape Analysis
- Language Mechanics On Memory Profiling
- Design Philosophy On Data And Semantics
介绍
在第一篇文章中,通过使用一个示例(在协程的栈中共享一个值)讲解了基本的指针语义。我并没有向您展示当在栈中共享一个值会发生什么。为了理解这点,您需要学习另一个值可以生成的区块:堆。有了这些知识,你就可以开始学习“逃避分析”。
逃逸分析是编译器用来确定程序创建的值的位置的过程。具体地,编译器执行表态代码分析,来决定是否可以将值放置在函数构造的栈帧上,或该值是否必须“逃逸”到堆上。在Go中,没有可以用关键字或函数可以用来直接告诉编译器做这个决定。只有通过您如何编写代码的约定才能决定这个决定。
堆
堆是除了栈以外,用来存储值的第二个内存区域。堆不像栈一样是自我清理的,所以使用该内存有一个巨大的开销。基本上开销和垃圾回收(GC)相关联,它必须参与保持区块清理。当GC运行时,它将使用你可用CPU能力的25%,它潜在的创造微秒级的“世界停止”的延迟。拥有GC的好处是你不必须关心管理堆内存,这在历史上一直是复杂且容易出错的。
堆上的值构成Go中的内存分配。这些分配给GC带来了压力,因为堆上不再被指针引用的每个值都需要删除。需要检查和删除的值越多,GC在每次运行时必须执行的工作就越多。因此,速度调整算法不断地工作,以平衡堆的大小和运行速度。
共享栈
在Go中,不允许goroutine拥有指向另一个goroutine堆栈上内存的指针。这是因为协程的栈内存在增长和收缩时是可以被新的内存块替换的。如果运行时必须追踪指向另一个协程栈指针,那么管理起来就太多了,更新这些堆栈上的指针的“世界停止”延迟将是无法承受的
这儿有个栈的例子,它由于增长被替换很多次。查看输出的第2到第6行。你将看到位于main
栈帧中 string
值的地址改变两次。
https://play.golang.org/p/pxn5u4EBSI
逃逸机制
在任何时候,值在函数栈帧外被共享,它将被放到(或分配)椎上,逃逸分析算法的工作是找到这些情形并保持程序的完整性。完整性在于确保获取任何价值总是准确、一致和高效的。
请看这个例子来学习逃逸分析背后的机制
https://play.golang.org/p/Y_VZxYteKO
Listing 1
|
|
我使用 go:noinline
来直接阻止编译器在 main
中内联这些代码。内联将会清除函数调用并复杂化这个示例。我将在下篇文章介绍内联的副作用
在 Listing 1,你看程序有两个不同的函数,创建一个 user
值并将值返回给调用者。函数的第一个版本是返回中使用值语义。
Listing 2
|
|
我说函数在返回中使用值语义,因为 user
值被该函数创建并拷贝然后传递给上层的调用栈。这意味着调用函数接收一个值本身的副本。
可以在第17行到第20行看到 user
值被构造。然后在23行,user
副本被传递给上层调用栈并返回给调用者,在函数返回后,栈看起来如下:
Figure 1
可以在表1中看到,user
的值在调用 createUserV1
之后,同时在两个帧上存在。在函数的版本2中,指针语义被使用并返回。
Listing 3
|
|
我说函数在返回时使用指针语义是因为 user
值被该函数创建并和调用栈共享。这意味着调用函数接收一个值地址的副本。
您可以看到在第28行到第31行上使用相同的struct literal来构造 user
值,但在第34行上返回的结果不同。不是将 user
值的副本传递回调用堆栈,而是向上传user
值的地址副本。基于此,您可能认为在调用之后堆栈看起来是这样的。
Figure 2
如果您在表2所看到的是真正发生的,你将拥有一个完整性的问题。指针指向下面调用栈的内存不再有效。在 main
调用的另一个函数,被指向的内存将被再框住并再次初始化。
这是逃逸分析开始保持完整性的地方。在这个例子中,编译器将决定在 createUserV2
的栈帧中构造 user
值是不安全的,所以在椎上构造值作为替换。这将在第28行的构造中立即发生。
可读性
正如您在上一篇文章所了解的,函数在帧内部,通过指针可以直接访问内存,但是在它帧外部访问内存,需要间接访问。这意味着访问逃逸到堆上的值也必须通过指针间接访问。
记住 createUserV2
的代码:
Listing 4
|
|
在这段代码,语法隐藏了真正发生的事。在第 28 行声明的变量 u
代表一个类型为 user
的值。Go中的构造并不会告诉你值生存在内存的何处,所以直到第 34 行的 return
语句,你才知道值需要逃逸。这意味着,即使 u
代表类型为 user
的值,访问这个 user
值必须通过底层的指针。
你可以看到在函数调用后栈情况:
Figure 3
在函数 createUserV2
的栈帧上的变量 u
,代表在堆上的值,而不是栈上。这意味着,使用 u
来访问变量,需要指针访问,而不是直接建议的直接访问语法。您可能会想,既然访问 u
所表示的值需要使用指针,那么为什么不将u设为指针呢?
Listing 5
|
|
如果你这样做了,您将远离代码中的一个重要可读性增益。离开整个函数一秒钟,专注于return
。
Listing 6
|
|
这个 return
告诉了你什么?它会说它返回一个 u
的副本,被向上传递到调用栈。然而,当使用 &
操作符时,该 return
会告诉你什么?
Listing 7
|
|
感谢 &
操作符,return
现在告诉你 u
被调用栈共享,它将逃逸到堆上。记住,指针是用于共享的,在阅读代码时,指针将替换 u
操作符的共享单词。这是非常强大的可读性方面,你不想失去的东西。
这儿有另一个示例,它使用指针语义构造值,但有损可读性。
Listing 8
|
|
为了代码的工作,你必须和第02行的 json.Unmarshal
调用共享指针变量。json.Unmarshal
调用将创建 user
值并指定他的地址到指针变量。https://play.golang.org/p/koI8EjpeIx
这段代码说了什么:
01:创建一个
user
类型的指针并设置它的零值02:和
json.Unmarshal
函数共享u
03:返回
u
的拷贝给调用者
尚不清楚由 json.Unmarshal
函数创建的 u
值是否正在与调用方共享
当在构造的过程中使用值语义,可读性如何改变?
Listing 9
|
|
这段代码说了什么?
01:创建一个
user
类型的变量并设置字的零值02:和函数
json.Unmarshal
共享u
03:和调用者共享
u
一切清晰明了。第02行将调用栈中的 user
变量的值共享给 json.Unmarshal
,第03行将调用栈中的 user
值共享给调用者。这个共享将导致 user
值逃逸。
当构造值时使用值语义,并利用&
运算符的可读性以明确值的共享方式。
编译器报告
查看编译器的决定,可以告诉编译器提供一个报告。你所有需要做的是在 go build
调用时使用 -gcflags
来转换 -m
选项。
您实际上可以使用4个级别的-m
,但是超过2个级别的信息不胜枚举。 我将使用 -m
的2个级别。
Listing 10
|
|
你可以看编译器报告了逃逸决定。编译器说了什么?首先再看看引用的 createUserV1
和 createUserV2
函数
Listing 13
|
|
报告中的开始行
Listing 14
|
|
它说函数在 createUserV1
调用 println
不会导致 user
值逃逸到堆上。这必须被检查,因为它和 println
函数共享。
接下来看报告的这几行
Listing 15
|
|
这些行说明了,值 user
和变量 u
关联,该变量是类型为 user
的名字,在第31行被关联,它逃逸是因为 34 行的 return
。最后一行说和前面一样的内容,第33行的println
调用不会导致 user
逃逸。
阅读这些报告可能会造成混淆,并且可能会略有变化,具体取决于所讨论的变量类型是基于命名类型还是文字类型。
更改 u
为文字类型*user
,而不是之前的命名类型 user
。
Listing 16
|
|
再次运行报告
Listing 17
|
|
现在该报告说,由于第34行的 return
,被 u
变量引用的 user
值正在转义,该 u
变量是文本类型 *user
并在第28行分配的。
结论
值的构造并不能决定其所在位置。 只有值共享的方式才能确定编译器将如何使用该值。 每当您在调用堆栈中共享一个值时,它都会逃逸。 还有一个导致值逃逸的其他原因,您将在下一篇文章中进行探讨。
这些文章试图引导您找到为任何给定类型选择值或指针语义的准则。 每种语义都有收益和成本。 值语义将值保留在栈上,从而减轻了对GC的压力。 但是,必须存储,跟踪和维护任何给定值的不同副本。 指针语义将值放在堆上,这可能会对GC造成压力。 但是,它们是有效的,因为仅需要存储,跟踪和维护一个值。 关键是正确,一致且平衡地使用每种语义。