『就要学习 Go 语言』系列 -- 第 34 篇分享好文
原文地址:https://www.ardanlabs.com/blog/2017/05/language-mechanics-on-stacks-and-pointers.html
原文作者:William Kennedy
四哥水平有限,如有翻译或理解错误,烦请帮忙指出,感谢!
原文如下:
这个系列包含四篇文章,主要讲解 Go 言语指针、栈、堆、逃逸分析和值/指针语义背后的机制和设计理念。这是系列第一篇文章,主要讲解栈和指针。
我并不打算为指针说好话,它确实很难理解。如果使用不当,会导致惹人厌的 bug,甚至是性能问题。在编写并发或多线程软件时尤其如此。这也难怪很多编程语言都试图为程序员规避使用指针。然而,如果使用 Go 语言编程程序,指针是无法避免的。只有深入理解指针,你才能够写出干净、简洁且有效率的代码。
帧边界为每个函数提供了单独的内存空间,函数就在帧边界范围内执行。帧边界允许函数在自己的上下文中运行,还提供流程控制。函数可以通过帧指针直接访问帧内的内存,而访问帧外内存只能通过间接的方式。对于每个函数来说,若想能够访问到帧外的内存,这块内存必须与函数共享。要想知道共享实现的,我们需要先学习和理解帧边界建立的机制和限制条件。
当一个函数被调用时,两个帧边界之间会发生上下文切换。从调用函数到被调用函数,如果函数调用时需要传递参数,这些参数也必须传递要被调函数的帧边界之内。Go 语言里面,两个帧之间的数据传递是按值传递的。
按值传递数据的优点是可读性好。在函数调用时,你看到的值就是在函数调用者和被调用者之间被复制和接收的值。这就是为什么我把“按值传递”与所见即所得联系在一起,因为你看到的就是你得到的。
让我们来看一段按值传递整型数据的代码:
清单1
package main
func main() {
// Declare variable of type int with a value of 10.
count := 10
// Display the "value of" and "address of" count.
println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")
// Pass the "value of" the count.
increment(count)
println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")
}
//go:noinline
func increment(inc int) {
// Increment the "value of" inc.
inc++
println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]")
}
当你启动 Go 程序时,运行时将会创建主协程去执行所有的初始化代码包括 main() 函数里面的代码。goroutine 是一个放置在操作系统线程上面的执行路径,最终在某一个内核上执行。从 Go 1.8 版本中,每一个 goroutine 将会分配 2048 字节的连续内存块作为它的栈空间。几年以来,初始栈空间的大小一直在变化,以后还可能再次改变。
栈非常重要,因为它为每个单独函数的帧边界提供了物理内存空间。按照清单 1 ,当主协程执行 main() 函数的时候,栈空间的分布如下图这样:
图 1
你可以看到图一,主函数的栈的一部分已经被框出来了。这部分称为“栈帧”,这个帧表示主函数在栈上的边界。帧是被调用函数执行的时候建立的,你还可以看到,变量 count 被放置在 main() 函数帧里面、内存地址为 0x10429fa4 位置。
图一还说明了另外一个有趣的点,活动帧以下的所有栈内存是不可用的,只有活动帧及其以上的栈内存是可用的。可用栈空间和不可用栈空间之间的边界需要明确下。
变量的目的就是给特定的内存地址分配一个名称,使代码的可读性更强并且帮助你分析正在处理的数据。如果你有一个变量就能得到它保存在内存的值,内存地址中肯定有一个地址保存这个值。第 9 行代码,main() 函数调用内置函数 println() 显示变量 count 的值和地址。
清单2
println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")
使用 & 运算符获取变量所在内存位置的地址并不奇怪,其他语言也使用这个运算符。如果你的代码运行在 32 位电脑上,例如:go playground,那么输出会类似于下面这样:
清单3
count: Value Of[ 10 ] Addr Of[ 0x10429fa4 ]
接下来的第 12 行代码,main() 函数调用 increment() 函数。
清单4
increment(count)
调用函数意味着协程需要在栈上构建出一块新的栈帧。但是,事情有点复杂。要想成功地调用函数,在发生上下文切换时,数据需要跨越帧边界传递到新的帧范围内。具体一点来说,函数调用的时候,整型值会被复制和传递。通过第 18 行代码、increment() 函数的声明,你就可以知道。
清单5
func increment(inc int) {
如果你回过头来再次看第 12 行代码函数 increment() 的调用,你会发现 count 变量是传值的。这个值会被拷贝、传递,最后存储在 increment() 函数的栈中。记住,increment() 函数只能在自己的栈内读写内存,因此,它需要 inc 变量来接收、存储和访问传递的 count 变量的副本。
就在 increment() 函数内部代码开始执行之前,协程的栈(站在一个非常高的角度)应该是像下图这样的:
图 2
你可以看到栈上现在有两个帧,一个属于 main() 函数,另一个属于 increment() 函数。在 increment() 函数的帧里面,你可以看到 inc 变量,它的值 10,是函数调用时拷贝、传递进来的。变量 inc 的地址是 0x10429f98,因为栈帧是从上至下使用栈空间的,所以它的内存地址较小,这只是具体的实现细节,并没任何意义。重要的是,协程从 main() 的栈帧里获取变量 count 的值,并使用 inc 变量将该值的副本放置在 increment() 函数的栈帧里。
increment() 函数的剩余代码显示 inc 变量的值和地址。
清单6
inc++
println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]")
第 22 行代码输出类似下面这样:
清单7
inc: Value Of[ 11 ] Addr Of[ 0x10429f98 ]
执行这些代码之后,栈就会像下面这样:
图 3
第 21、22 行代码执行之后,increment() 函数返回并且 CPU 控制权交还给 main() 函数。第 14 行代码,main() 函数会再次显示 count 变量的值和地址。
清单8
println("count:\tValue Of[",count, "]\tAddr Of[", &count, "]")
上面例子完整的输出会像下面这样:
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() 函数返回时,栈上的空间看起来像下面这样:
图 4
除了为 increment() 函数创建的栈帧变得不可用之外,栈的分布与图 3 基本是一样的。这是因为 main() 函数的帧变成了活动帧。对 increment() 函数的栈帧不做任何处理。
函数返回时,清理函数的帧会浪费时间,因为你不知道还会不会再次使用这块内存。所以这块内存就不会做任何处理。每次函数调用的时候,当需要帧的时候,栈上开辟的帧会被清理。这是通过存储在该帧里的变量初始化时完成的。因为所有的值会初始化成对应的零值,每次函数调用时,栈都会正确地完成自我清理工作。
如果 increment() 函数直接操作存储在 main() 函数帧里面的 count 变量非常重要,那该怎么办?这就要用到指针!指针的目的就是实现在函数间共享值,即使这个值不在自己函数的帧里面,函数也能够对它进行读写。
如果脑海里没有共享的概念,你可能不会使用指针。学习指针时,重要的是使用清晰的词汇,而不是单纯地记住操作符或者语法。因此,请记住,指针是用于共享的并且在阅读代码时,提到“共享”时,就应该想到 & 操作符。
不管是你自定义的或者是 Go 语言自带的,对于每一种已声明的类型,都可以基于这些类型获得对应的指针类型用于共享。例如内置类型 int,对应的指针类型是 *int。如果你自己声明了类型 User,对应的指针类型就是 *User。
所有的指针类型有相同的特点。首先,它们以 * 符号开头;其次,占用相同的内存空间并且都表示一个地址,使用 4 个或 8 个字节长度表示一个地址。在 32 位机器上(例如 playground ),指针需要 4 个字节的内存空间;在 64 位机器上(例如你的电脑),需要 8 个字节的内存空间。
规范里有说明,指针类型可以看成是类型字面量,这意味着它们是有现有类型组成的未命名类型。
让我们来看一段代码,这段代码展示了函数调用时按值传递地址。main() 和 increment() 函数的栈帧会共享 count 变量:
清单10
package main
func main() {
// Declare variable of type int with a value of 10.
count := 10
// Display the "value of" and "address of" count.
println("count:\tValue Of[", count, "]\t\tAddr Of[", &count, "]")
// Pass the "address of" count.
increment(&count)
println("count:\tValue Of[", count, "]\t\tAddr Of[", &count, "]")
}
//go:noinline
func increment(inc *int) {
// Increment the "value of" count that the "pointer points to". (dereferencing)
*inc++
println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]\tValue Points To[", *inc, "]")
}
基于原来的代码有三处改动的地方,第 12 行是第一处改动:
清单11
increment(&count)
现在,第 12 行代码拷贝、传递的并非 count 变量的值,而是变量的地址。可以认为,main() 函数与 increment() 函数是共享 count 变量的。这是 & 操作符起的作用。
重点理解,现在依旧是传值,唯一不同的是现在传递的是地址而不是一个整型数据。地址也是一个值,是函数调用时会跨帧边界发生拷贝和传递的内容。
因为地址会发生拷贝和传递,在 increment() 函数里面需要一个变量接收和存储该地址值。所以在第 18 行声明了整型的指针变量。
清单12
func increment(inc *int) {
如果你传递的是 User 类型值的地址,变量就应该声明成 *User。尽管指针变量存储的是地址,也不能传递任何类型的地址,只能传递与指针类型相一致的地址。关键在于,共享值的原因是因为接收函数能够对值进行读写操作。只有知道值的类型信息才能够进行读写操作。编译器会保证只有与指针类型相一致的值才能够实现函数间共享。
调用 increment() 函数时候,栈空间就像下面这样:
图 5
当一个地址作为值执行按值传递之后,你可以从图 5 看出栈是如何分布的。现在,increment() 函数帧空间里面的指针变量指向 count 变量,该变量在 main() 函数的帧空间里。
通过使用指针变量,increment() 函数可以间接对 count 变量执行读写操作。
清单 13
*inc++
这一次,字符 * 充当操作符,与指针变量搭配使用。使用 * 操作符是“获取指针指向的值”的意思。指针变量允许在帧外对函数帧内的内存进行间接访问。有时候,间接的读写操作也称为解引用。increment() 函数必须有指针变量,才能够对其他函数帧空间执行间接访问。
执行第 21 行代码之后,栈空间分布如图 6 所示。
图 6
程序最后输出:
清单 14
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 语言中栈和指针的工作机制。这是理解 Go 语言机制、设计哲学的第一步,也对编写一致性且可读性的代码提供一些指导作用。
总结一下,通过这篇文章你能学习到的知识:
1.帧边界为每个函数提供了单独的内存空间,函数就在帧范围内执行;2.当函数调用时,上下文环境会在两个帧之间发生切换;3.按值传递的优点是可读性好;4.栈很重要,因为它为每个函数的帧边界提供了可访问的物理内存空间;5.活动帧以下的所有栈内存是不可用的,只有活动帧及其上方的栈内存是有用的;6.调用函数意味着协程会在栈内存上开辟一块新的栈帧;7.每次函数调用的时候,当使用到帧时,相应的栈内存会被初始化;8.设计指针的目的是实现函数间值共享,即使该值不在函数自己栈帧里,也能对其进行读写操作;9.对于每一种类型,不管是自己定义的还是 Go 语言内置的,都有相应的指针类型;10.通过使用指针变量,允许在函数帧外进行间接内存访问;11.与其他变量相比,指针变量并没有特别之处,因为它们也是变量,有内存地址和值。
推荐阅读:
800 字彻底理解 Go 指针
指针(译)
如果我的文章对你有所帮助,点赞、转发都是一种支持!
给个[在看],是对四哥最大的支持