楔子

这是一个由四部分组成的系列文章的第二篇,该系列文章将提供对Go中指针,栈,堆,逃逸分析和值/指针语法背后设计和机制的理解。这篇文章主要关注堆栈和指针。

四部分系列文章索引:

  1. Language Mechanics On Stacks And Pointers
  2. Language Mechanics On Escape Analysis
  3. Language Mechanics On Memory Profiling
  4. 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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 来直接阻止编译器在 main 中内联这些代码。内联将会清除函数调用并复杂化这个示例。我将在下篇文章介绍内联的副作用

在 Listing 1,你看程序有两个不同的函数,创建一个 user 值并将值返回给调用者。函数的第一个版本是返回中使用值语义。

Listing 2

1
2
3
4
5
6
7
8
9
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 副本被传递给上层调用栈并返回给调用者,在函数返回后,栈看起来如下:

Figure 1

img

可以在表1中看到,user 的值在调用 createUserV1 之后,同时在两个帧上存在。在函数的版本2中,指针语义被使用并返回。

Listing 3

1
2
3
4
5
6
7
8
9
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 值被该函数创建并和调用栈共享。这意味着调用函数接收一个值地址的副本。

您可以看到在第28行到第31行上使用相同的struct literal来构造 user 值,但在第34行上返回的结果不同。不是将 user 值的副本传递回调用堆栈,而是向上传user值的地址副本。基于此,您可能认为在调用之后堆栈看起来是这样的。

Figure 2

img

如果您在表2所看到的是真正发生的,你将拥有一个完整性的问题。指针指向下面调用栈的内存不再有效。在 main 调用的另一个函数,被指向的内存将被再框住并再次初始化。

这是逃逸分析开始保持完整性的地方。在这个例子中,编译器将决定在 createUserV2的栈帧中构造 user 值是不安全的,所以在椎上构造值作为替换。这将在第28行的构造中立即发生。

可读性

正如您在上一篇文章所了解的,函数在帧内部,通过指针可以直接访问内存,但是在它帧外部访问内存,需要间接访问。这意味着访问逃逸到堆上的值也必须通过指针间接访问。

记住 createUserV2 的代码:

Listing 4

1
2
3
4
5
6
7
8
9
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

img

在函数 createUserV2 的栈帧上的变量 u,代表在堆上的值,而不是栈上。这意味着,使用 u 来访问变量,需要指针访问,而不是直接建议的直接访问语法。您可能会想,既然访问 u 所表示的值需要使用指针,那么为什么不将u设为指针呢?

Listing 5

1
2
3
4
5
6
7
8
9
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 }

如果你这样做了,您将远离代码中的一个重要可读性增益。离开整个函数一秒钟,专注于return

Listing 6

1
2
34     return u
35 }

这个 return 告诉了你什么?它会说它返回一个 u 的副本,被向上传递到调用栈。然而,当使用 & 操作符时,该 return 会告诉你什么?

Listing 7

1
2
34     return &u
35 }

感谢 & 操作符,return 现在告诉你 u 被调用栈共享,它将逃逸到堆上。记住,指针是用于共享的,在阅读代码时,指针将替换 u 操作符的共享单词。这是非常强大的可读性方面,你不想失去的东西。

这儿有另一个示例,它使用指针语义构造值,但有损可读性。

Listing 8

1
2
3
01 var u *user
02 err := json.Unmarshal([]byte(r), &u)
03 return u, err

为了代码的工作,你必须和第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

1
2
3
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 值共享给调用者。这个共享将导致 user 值逃逸。

当构造值时使用值语义,并利用&运算符的可读性以明确值的共享方式。

编译器报告

查看编译器的决定,可以告诉编译器提供一个报告。你所有需要做的是在 go build 调用时使用 -gcflags 来转换 -m 选项。

您实际上可以使用4个级别的-m,但是超过2个级别的信息不胜枚举。 我将使用 -m 的2个级别。

Listing 10

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ 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

你可以看编译器报告了逃逸决定。编译器说了什么?首先再看看引用的 createUserV1createUserV2 函数

Listing 13

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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

1
./main.go:22: createUserV1 &u does not escape

它说函数在 createUserV1 调用 println 不会导致 user 值逃逸到堆上。这必须被检查,因为它和 println 函数共享。

接下来看报告的这几行

Listing 15

1
2
3
4
./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

这些行说明了,值 user 和变量 u 关联,该变量是类型为 user 的名字,在第31行被关联,它逃逸是因为 34 行的 return。最后一行说和前面一样的内容,第33行的println 调用不会导致 user 逃逸。

阅读这些报告可能会造成混淆,并且可能会略有变化,具体取决于所讨论的变量类型是基于命名类型还是文字类型。

更改 u 为文字类型*user,而不是之前的命名类型 user

Listing 16

1
2
3
4
5
6
7
8
9
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

1
2
3
./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行的 return,被 u变量引用的 user 值正在转义,该 u 变量是文本类型 *user 并在第28行分配的。

结论

值的构造并不能决定其所在位置。 只有值共享的方式才能确定编译器将如何使用该值。 每当您在调用堆栈中共享一个值时,它都会逃逸。 还有一个导致值逃逸的其他原因,您将在下一篇文章中进行探讨。

这些文章试图引导您找到为任何给定类型选择值或指针语义的准则。 每种语义都有收益和成本。 值语义将值保留在栈上,从而减轻了对GC的压力。 但是,必须存储,跟踪和维护任何给定值的不同副本。 指针语义将值放在堆上,这可能会对GC造成压力。 但是,它们是有效的,因为仅需要存储,跟踪和维护一个值。 关键是正确,一致且平衡地使用每种语义。