阅读前请悉知:本文是一篇翻译文章,出于对原文的喜爱与敬畏,所以需要强调:如果读者英文阅读能力好,请直接移步文末原文链接;如果对这篇翻译所述知识感兴趣,也请一定要再看下英文原文,加深理解。翻译中为了表达的需要,加入了自己的一些理解,不过因为知识有限,翻译过程难免纰漏,如有问题,欢迎留言指正。
前言
在这个由四部分组成的系列的第一篇文章中,我使用了一个例子来介绍指针机制的基础知识,在这个例子中,一个值被共享到goroutine
的栈中。我没有向你们展示的是当你在栈上共享一个值时会发生什么。要理解这一点,你需要了解另一个内存区域:堆。有了这些知识,你就可以开始学习逃逸分析了。
逃逸分析是编译器用来确定由程序创建的值所处位置的过程。具体来说,编译器执行静态代码分析,以确定是否可以将值放在构造函数的栈(帧)上,或者该值是否必须“逃逸”到堆上。在Go中,没有关键字或函数可以用于在此决策中指导编译器。只有通过你写的代码来分析这一点。
堆
堆是除栈之外的第二个内存区域,用于存储值。堆不像栈那样是自清理的,因此使用这个内存的成本更大。首先,成本与垃圾收集器(GC)有关,垃圾收集器必须参与进来以保持该区域的清洁。当GC运行时,它将使用25%的可用CPU资源。此外,它可能会产生微秒级的“stop the world”延迟。拥有GC的好处是你不需要担心内存的管理问题,因为内存管理是相当复杂、也容易出错的。
堆上的值构成Go中的内存分配。这些分配对GC造成压力,因为堆中不再被指针引用的每个值都需要删除。需要检查和删除的值越多,GC每次运行时必须执行的工作就越多。因此,GC算法一直在努力在堆的大小分配和运行速度之间寻求平衡。
共享栈
在Go中,不允许goroutine
拥有指向另一个goroutine
栈上的内存的指针。这是因为当栈必须增长或收缩时,goroutine
的栈内存可能被一个新的内存块替换。如果运行时必须跟踪指向其他goroutine
栈的指针,那么管理起来就太困难了,而在这些栈
上更新指针的“stop the world”延迟将会非常困难。
下面是一个由于增长而多次被替换的栈示例。查看第2行和第6行的输出。你将在main
的栈(帧)中看到字符串值的地址更改了两次。(字符串s
的内存地址本来应该是在main的帧内的,为何会发生这种变化呢?没搞懂)
// Sample program to show how stacks grow/change.
package main
// Number of elements to grow each stack frame.
// Run with 10 and then with 1024
const 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)
}
输出
0 0x1044dfa0 HELLO
1 0x1044dfa0 HELLO
2 0x10455fa0 HELLO
3 0x10455fa0 HELLO
4 0x10455fa0 HELLO
5 0x10455fa0 HELLO
6 0x10465fa0 HELLO
7 0x10465fa0 HELLO
8 0x10465fa0 HELLO
9 0x10465fa0 HELLO
逃逸机制
在函数的栈(帧)之外共享一个值时,它将被放置(或分配)在堆上。逃逸分析算法的工作是找到这些情况,并在程序中保持一定的完整性。完整性在于确保对任何值的访问总是准确、一致和高效的。
Listing 1
01 package main
02
03 type user struct {
04 name string
05 email string
06 }
07
08 func main() {
09 u1 := createUserV1()
10 u2 := createUserV2()
11
12 println("u1", &u1, "u2", &u2)
13 }
14
15 //go:noinline
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 }
25
26 //go:noinline
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 }
我正在使用go:noinline
指令,以防止编译器直接内联这些函数的代码。内联将删除函数调用并使这个示例复杂化。我将在下一篇文章中介绍内联的副作用。
在清单1中,你将看到一个具有两个不同函数的程序,它们创建用户值并将值返回给调用者。createUserV1
在返回时使用了值语义。
Listing 2
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 }
我说过函数在返回时使用值语义,因为这个函数创建的用户值正在被复制并传递给调用栈。这意味着调用函数正在接收值本身的副本。
你可以看到在第17行到第20行执行了用户值的构造。然后在第23行,用户值的副本被传递到调用栈并返回给调用者。函数返回后,栈是这样的。
Figure 1
在图1中可以看到,在调用createUserV1
之后,两个帧中都存在一个用户值。在函数的createUserV2
中,在返回时使用指针语义。
Listing 3
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到31行中使用了相同的struct文字构造用户值,但是在第34行中返回的值不同。不是将用户值的副本传递回调用栈,而是传递用户值的地址副本。基于此,你可能认为调用之后栈是这样的。
Figure 2
如果你在图2中看到的真的发生了,那么你就会遇到完整性问题。指针向下指向不再有效的调用栈。在main的下一个函数调用中,所指向的内存将被重新构造并重新初始化。
这就是逃逸分析开始维护完整性的地方。在这种情况下,编译器将确定在createUserV2
的栈桢内构造用户值是不安全的,因此它将在堆上构造值。这将由第28行初始化完成。
可读性
正如你在上一篇文章中学到的,在所属帧内,函数可以通过指针直接访问桢内的内存,但是访问帧外的内存需要间接访问。这意味着对转义到堆的值的访问也必须通过指针间接完成。
记住createUserV2
的代码是什么样子的。
Listing 4
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行上的return
语句,你才知道这个值需要逃逸。这意味着,即使u
表示的是user
类型的值,访问这个user
值也必须通过封面下面的指针进行。
你可以在函数调用之后将内存布局形象化。
Figure 3
createUserV2
的栈(帧)上的u
变量表示堆上的值,而不是栈上的值。这意味着使用u
访问值,需要指针访问,而不是语法建议的直接访问。你可能会想,既然访问它所代表的值需要使用指针,那么为什么不让u
成为指针呢?
Listing 5
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 }
如果你这样做,会牺牲代码的�可读性。暂时离开整个函数,只关注返回值。
Listing 6
34 return u
35 }
这还告诉你什么?它说的是一个u
的副本被传递到调用栈上。然而,当你使用&
操作符时,返回告诉你什么?
Listing 7
34 return &u
35 }
多亏了&
运算符,返回现在告诉因为u
需要共享到调用栈中,由此逃逸到堆中。记住,指针是用于共享的,并在读取代码时替换“共享”一词的&
操作符。这在可读性方面非常强大,这是你不想失去的。
下面是另一个例子,使用指针语义构造值会损害可读性。
Listing 8
01 var u *user
02 err := json.Unmarshal([]byte(r), &u)
03 return u, err
你必须与json
共享指针变量。在第02行调用Unmarshal
,让这段代码工作。json.Unmarshal
调用将创建用户值并将其地址分配给指针变量。
这段代码说了什么:
01:创建一个类型为user
的指针变量。
02:与json.Unmarshal
函数共享u
。
03:给调用方返回u
的副本。
user
的值是否由json.Unmarshal
函数创建并与调用者共享尚不清楚。
在构造过程中使用值语义时可读性如何变化?
Listing 9
01 var u user
02 err := json.Unmarshal([]byte(r), &u)
03 return &u, err
这段代码说了什么:
01:创建一个类型为user
的指针变量。
02:与json.Unmarshal
函数共享u
。
03:与调用方共享u
。
一切都很清楚。第02行将调用栈中的user
值共享给json.Unmarshal
函数以及第03行将user
值从调用栈上共享给调用者。此共享将导致用户值转义。
在构造值时使用值语义,并利用&
运算符的可读性来明确值是如何被共享的。
编译器报告
要查看编译器正在做出的决策,你可以要求编译器提供一个报告。你所需要做的就是在go build
调用中使用带有-m
选项的-gcflags
开关。
实际上有4个级别的-m
可以使用,但是超过2个级别的信息就会让人不知所措。我将使用-m
的两个级别。
Listing 10
$ 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
和createUserV2
函数以供参考。
Listing 13
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 }
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 }
从报告中的这一行开始。
Listing 14
./main.go:22: createUserV1 &u does not escape
这就是说,在createUserV1
函数中对println
的函数调用不会导致用户值转义到堆中。必须检查它,因为它正在与println
函数共享。
接下来看看报告中的这些行。
Listing 15
./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
这些行表示,与u
变量关联的user
值(它是命名类型user
,在第31行分配)因为在第34行返回而转义。最后一行和前面一样,第33行上的println
调用不会导致用户值转义。
阅读这些报告可能会让人感到困惑,并可能会根据所涉及的变量类型是基于命名类型还是基于文字类型而略有变化。
将u
更改为文字类型*user
,而不是之前的命名类型user
。
Listing 16
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 }
再回头看报告
Listing 17
./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,在第28行赋值。
结论
一个值的构造并不决定它的位置。只有如何共享一个值才能决定编译器将如何处理该值。任何时候你在调用栈上共享一个值,它都会被转义。在下一篇文章中,你将探讨其他原因来解释值的转义。
这些帖子试图引导你为任何给定类型选择值或指针语义的指导原则。每种语义都有其优点和代价。值语义将值保存在栈上,从而减少对GC的压力。但是,任何给定值都有不同的副本,必须存储、跟踪和维护。指针语义将值放在堆上,这会对GC造成压力。但是,它们是高效的,因为只有一个值需要存储、跟踪和维护。关键是正确、一致和平衡地使用每个语义。
版权声明:
任何个人或机构如需转载本文,无须再获得作者书面授权,但是转载者必须保留作者署名,并注明出处。
作者保留对本文的修改权。他人未经作者许可,不得擅自修改,破坏作品的完整性。
作者保留对本文的其他各项著作权权利。