Go 语言机制之栈与指针

『就要学习 Go 语言』系列 -- 第 34 篇分享好文php

原文地址:https://www.ardanlabs.com/blog/2017/05/language-mechanics-on-stacks-and-pointers.html
原文做者:William Kennedyhtml

四哥水平有限,若有翻译或理解错误,烦请帮忙指出,感谢!c++

原文以下:程序员

序言

这个系列包含四篇文章,主要讲解 Go 言语指针、栈、堆、逃逸分析和值/指针语义背后的机制和设计理念。这是系列第一篇文章,主要讲解栈和指针。编程

介绍

我并不打算为指针说好话,它确实很难理解。若是使用不当,会致使惹人厌的 bug,甚至是性能问题。在编写并发或多线程软件时尤为如此。这也难怪不少编程语言都试图为程序员规避使用指针。然而,若是使用 Go 语言编程程序,指针是没法避免的。只有深刻理解指针,你才可以写出干净、简洁且有效率的代码。多线程

帧边界

帧边界为每一个函数提供了单独的内存空间,函数就在帧边界范围内执行。帧边界容许函数在本身的上下文中运行,还提供流程控制。函数能够经过帧指针直接访问帧内的内存,而访问帧外内存只能经过间接的方式。对于每一个函数来讲,若想可以访问到帧外的内存,这块内存必须与函数共享。要想知道共享实现的,咱们须要先学习和理解帧边界创建的机制和限制条件。并发

当一个函数被调用时,两个帧边界之间会发生上下文切换。从调用函数到被调用函数,若是函数调用时须要传递参数,这些参数也必须传递要被调函数的帧边界以内。Go 语言里面,两个帧之间的数据传递是按值传递的。编程语言

按值传递数据的优势是可读性好。在函数调用时,你看到的值就是在函数调用者和被调用者之间被复制和接收的值。这就是为何我把“按值传递”与所见即所得联系在一块儿,由于你看到的就是你获得的。ide

让咱们来看一段按值传递整型数据的代码:函数

清单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

第 2一、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 指针

指针(译)

若是个人文章对你有所帮助,点赞、转发都是一种支持!

给个[在看],是对四哥最大的支持