楔子

这是一个由四部分组成的系列文章的第一篇,该系列文章将提供对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写软件,就没有办法避免它们。对指针没深刻的理解,你就无法编写出整洁,简单和高效的代码。

帧边界

函数在帧边界内的作用域中执行,而帧分别为每个函数提供独立的内存空间。每个帧允许函数操作它们自己的上下文,同时提供流程控制。函数通过帧指针,可以直接访问自己帧的内存,但是访问它自己帧外的内存需要非直接访问。函数访问它自己帧外的内存,则内存必须和函数共享。该机制和限制通过这些帧边界来建立,帧边界是需要首先学习和理解的。

当一个函数被调用,两个帧之间分发生转换。代码会从正在调用的函数的帧转换到被调用函数的帧。如果在函数调用中需要数据,则数据必须从一个帧转换到另一个帧。在Go中在两个帧中传递数据通过“传值”来完成。

通过“传值”来传递数据的优点是可读性。在函数调用中看到的值就是在另一端拷贝和接收的值。这就是为什么我将“传值”和所见即所得联系起来。因为你所看见的就是你得到的。所有这些允许你写代码时,不需要在两个函数中隐藏转移开销。这有助于维护一个好的心理模型,当转换发生时,每个函数调用将如何影响程序。

看下这个小程序,函数调用通过“传值”来传递整型数据:

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
01 package main
02
03 func main() {
04
05    // Declare variable of type int with a value of 10.
06    count := 10
07
08    // Display the "value of" and "address of" count.
09    println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")
10
11    // Pass the "value of" the count.
12    increment(count)
13
14    println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")
15 }
16
17 //go:noinline
18 func increment(inc int) {
19
20    // Increment the "value of" inc.
21    inc++
22    println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]")
23 }

当Go程序启动,运行时创建主协程来启动执行所有的初始化代码,包括 main 函数中的代码。一个协程就是一个被放置到操作系统线程上的执行路径,最终在某些核心上执行。例如在 1.8 版本,每个协程被分配初始 2048字节的连续内存块,这组成了它的栈空间。初始化栈大小在过去几年中发生了变化,这在未来可能会再次改变。

栈是重要的,因为它为帧边界提供了物理内存空间,该帧边界提供给每个独立的函数。当主协程执行清单1中的 main 函数时,协程的栈(在非常高的级别上)如下所示:

Figure 1

在列表1中可以看到,栈部分已经在 main 函数外被”框“住。该部分被称为”栈帧“,这个帧表示 main 函数在栈上的边界。帧作为当函数被调用时要执行的代码的一部分被建立。也可以看内存中的 count 变量已经被放到 main 帧内部的地址 0x10429fa4 上。

列表1还清楚地表明了另外一个有趣的点。活动帧以下的所有栈内存都无效,但是活动帧以上的内存是有效的。我需要弄清楚堆栈的有效部分和无效部分之间的界限。

地址

变量的作用是将一个名字分配给指定内存位置,以提高代码的可读性并帮助你对正在处理的数据进行推理。如果你有一个变量,则在内存中就有相应的值,同时如果在内存中有一个值,那么它必须有一个地址。在第 09 行,main 函数调用内置函数 println 打印变量 count 的值和地址。

Listing 2

1
09    println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")

使用与号 & 操作符来苑骊个变量的内存位置并不新鲜,其他语言也使用该操作符。如果你在32位架构的机器上运行代码,第 09 行的输出应该与下面的输出很类似。

Listing 3

1
count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]

函数调用

前往到12行,main 函数执行一个调用,执行 increment 函数。

Listing 4

1
12    increment(count)

做出函数调用意味着协程需要框住栈上内存的新部分。然而,事情有点儿复杂。为了成功进行函数调用,在转移期间,期望数据通过跨帧边界传递并放到新的帧上。特别是在调用期间,整型值期望被拷贝并传递。可以查看第18行的 increment 函数的声明来了解这个需求。

Listing 5

1
18 func increment(inc int) {

如果再将看12行的调用 increment 函数,你可以看到代码传递 count 变量的值。该值会为 increment 函数而被拷贝,传递并放入到新的帧中。记住 increment 函数仅可以直接读写它自己帧中的内存,所以它需要 inc 变量来接收,存储和访问传递给他的 count 变量的拷贝。

increment 函数被执行之前,协程的栈如下所示:

Figure 2

img

可以看到现在栈有两个帧,一个是 main 的,而另下面的另一个是 increment 的。在 increment 帧的内部,可以看到 inc 变量和它包含的值 10,是在函数调用期间被拷贝到传递过来的。变量 inc 的地址是 0x10429f98 ,在内存中位于下面,因为帧中的栈是向下的,这仅仅是实现细节,并不意味着什么。重要的是协程获取 main 帧中变量 count 的值,并使用 inc 变量将值的副本保存到在 increment 的帧中。

函数 increment 的其余代码增加并打印inc 变量的值和地址。

Listing 6

1
2
21    inc++
22    println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]")

第22行的输出如下:

1
inc:    Value Of[ 11 ]  Addr Of[ 0x10429f98 ]

这就是执行这些代码行后堆栈的样子:

Figure 3

img

在 21 和 22 行执行之后,increment 函数返回,并将控制返回给 main 函数。然后 main 函数在第 14 行再次打印本地 count 变量的值和地址

Listing 8

1
14    println("count:\tValue Of[",count, "]\tAddr Of[", &count, "]")

程序的完整输出如下:

Listing 9

1
2
3
count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]
inc:    Value Of[ 11 ]  Addr Of[ 0x10429f98 ]
count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]

函数 main 帧中的 count 的值在调用 increment 前后是一样的。

函数返回

当函数返回,控制返回给函数调用方,栈内存实际发生了什么?简单的答案是:没有什么东西。下面是 increment 函数返回后,栈实际上内容:

Figure 4

img

除了与函数 increment 关联的帧现在被认为是无效内存,栈看起来和Figure 3完成一样。这是因为 main 的帧现在是活跃状态。为函数 increment 而框住的内存现在是未修改的。

清理返回函数的内存被认为是浪费时间,因为你不知道该内存是否被再次需要。所以内存还保持原样。在每个函数调用期间,帧被使用,此时该帧的栈内存被清理。这通过初始化任何放到帧中的值来实现。因为所有的值都至少被初始化为它们自己的零值,栈在每次函数调用时都适当的清理他们自身。

共享值

对于函数 increment ,如果直接操作存在于 main 帧中的 count 变量是重要的,该如何办?这就是指针的用途。指针有一个作用,和函数共享值,所以函数可以读写这些值,即使这些值并不直接存在于他们自身的帧中。

如何”共享“没有从你的口中说出,你不必使用指针。当学习指针,最重要的是使用清晰的词汇表,而不是操作或语法。所以请记住,指针是为了共享的,当您阅读代码时,使用 & 操作符替换单词”共享“。

指针类型

对于您或语言本身声明的每个类型,您都可以免费获得一个完整的指针类型,用于共享。已经存在名为 int 的内置类型,因此存在名为 *int 的完整的指针类型。如果声明名为 User 的类型,则可以免费获得名为 *User 的指针类型。

所有的指针类型有同样的两个特殊。首先,他们以字符 * 作为开始。其实,他们拥有相同的内存大小和表示形式,即一个4或8字节大小的地址。在32位架构上,指针需要4字节的内存,在64位架构上,他们需要8字节大小的内存。

在规范中,指针类型被认为是类型文本,这意味着它们是由现有类型组成的未命名类型。

间接内存访问

看下这个小程序,它展示通过传值的等式传递函数调用一个地址。这将和 increment 函数共享位于 main 栈帧变量 count 的值:

Listing 10

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
01 package main
02
03 func main() {
04
05    // Declare variable of type int with a value of 10.
06    count := 10
07
08    // Display the "value of" and "address of" count.
09    println("count:\tValue Of[", count, "]\t\tAddr Of[", &count, "]")
10
11    // Pass the "address of" count.
12    increment(&count)
13
14    println("count:\tValue Of[", count, "]\t\tAddr Of[", &count, "]")
15 }
16
17 //go:noinline
18 func increment(inc *int) {
19
20    // Increment the "value of" count that the "pointer points to". (dereferencing)
21    *inc++
22    println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]\tValue Points To[", *inc, "]")
23 }

这里和源程序相比,有三个有意思的改动。第 12 行是第一个修改:

Listing 11

1
12    increment(&count)

此时在第12行,代码不是拷贝和传递 count 的值,而是使用 count 的地址代替。现在可以说,我和 increment 函数”共享“ count 变量。这是 & 操作符语言,”共享“。

理解这仍然是”值传递“,唯一不同的是,你传递的值是一个地址,而不是整型。地址也是值;这是在函数调用时,将被跨帧边界拷贝和传递的地方。

由于地址的值被拷贝和传递,在 increment 的帧中需要一个变量来接收和存储这个基于地址的整型。这就是18行要讲的声明整型指针变量。

Listing 12

1
18 func increment(inc *int) {

如果你传递 User 值的地址,则该变量需要被声明为 *User。即使所有的指针变量存储地址的值,但他们不能传递任意地址,仅能传递与指针类型关联的地址。这是重要的,原因是共享一个值是因为接收函数需要执行读写该值。你需要值的类型信息以读写它。编译器需要确保只有值与正确指针类型才可以在函数中共享。

下面是在函数调用 increment 之后的栈内容:

Figure 5

img

可以在 figure 5 中看到,当使用地址作为值执行“传值”时栈的样子。increment 函数框架内的指针变量现在指向 count变量,该变量位于 main 函数框架内。

现在使用指针变量,函数可以间接读,修改,写位于 main 帧内的 count 变量。

Listing 13

1
21    *inc++

此时,字符 * 充当运算符并作用于指针变量。使用 * 作为操作符,意味着”指针指向的值“。指针允许间接内存访问它使用的函数帧以外变量。有时该间接读或写被称为指针的解引用。函数 increment 的帧内必须有可以直接读取的指针变量来行使间接访问。

现在在 表6 可以看到在 21 行的执行之后的栈情况

Figure 6

img

下面是最终的输出:

Listing 14

1
2
3
count:  Value Of[ 10 ]   	   	Addr Of[ 0x10429fa4 ]
inc:    Value Of[ 0x10429fa4 ]  	Addr Of[ 0x10429f98 ]   Value Points To[ 11 ]
count:  Value Of[ 11 ]   	   	Addr Of[ 0x10429fa4 ]

可以看到指针变量 inc 的值和变量 count 的值是一样的。这设置了共享关系,允许间接访问帧外的内存进行替换。当通过 increment 函数的指针进行了写操作,当控制返回时,在 main 函数中是可见的。

指针变量并不特殊

指针变量并不特殊,因为他们和其他变量一样也是变量。他们朋内存指定并持有一个值。碰巧的是所有的指针变量,除了他们指向的类型的值,通常是同样的大小和表现。可以造成困惑的是 * 字符作在代码中为操作符,被用来声明指针类型。如果你辨别类型声明和指针操作,这将有助于减少困惑。

结论

这篇文章描述了指针背后的目的,和Go中栈和指针的机制。这是理解机制,设计哲学和指导持久性和可读性代码的第一步。

下面是总结:

  • 函数在帧边界作用域中执行,域边界为每个对应的函数提供独立的内存空间。
  • 当函数被调用,在两个帧中有一个转移和替换。
  • 通过”传值“来传递数据的一个好处是可读性。
  • 栈是重要的,因为他为每个独立的函数的帧边界提供物理内存空间。
  • 所以活动帧下面的下面是无效和,而活动帧上面的内存是有效的。
  • 进行函数调用意味着goroutine需要在堆栈上构建一个新的内存段。
  • 在每个函数调用期间,当使用了帧,帧的栈内存被清理。
  • 指针有一个目的,和函数共享值以便函数可以直接读写值,即使值不是直接存在它自己的帧中。
  • 通过人或语言本身声明的每个类型,将免费获得一个可以直接使用的完整的指针类型。
  • 指针变量允许间接访问它使用函数帧外的内存
  • 指针变量并不特殊,因为他们和其他变量一样也是变量。他们拥有指定的内存并持有变量。