堆栈和指针的语言机制

序幕


这是该系列文章中的四篇文章的第一篇,将深入了解指针,堆栈,堆,转义分析和Go /指针语义的机制和设计。这篇文章是关于堆栈和指针的。

目录:

  1. 堆栈和指针上的语言力学
  2. 逃生分析的语言力学翻译
  3. 记忆剖析的语言机制
  4. 数据与语义设计哲学

介绍


我不会反汇编-指针很难理解。如果使用不当,指针可能会导致令人不愉快的错误,甚至导致性能问题。在编写竞争性或多线程程序时尤其如此。毫不奇怪,许多语言都试图隐藏程序员的指针。但是,如果使用Go编写,则无法转义指针。如果没有对指针的清楚理解,您将很难编写干净,简单而有效的代码。

边框


在帧的边界内执行功能,这些帧为每个相应功能提供了单独的存储空间。每个框架都允许该功能在其自己的上下文中工作,并提供流控制。函数可以通过指针直接访问其框架内的内存,但是访问框架外部的内存则需要间接访问。为了使功能在其框架之外访问存储器,必须将该存储器与该功能结合使用。必须首先了解和研究由这些边界设置的机制和限制。

调用函数时,会在两个帧之间发生过渡。代码从调用函数的框架到被调用函数的框架。如果需要数据来调用该函数,则必须将该数据从一帧传输到另一帧。 Go中两个帧之间的数据传输是“按值”完成的。

“按值”数据传输的优点是可读性。您在函数调用中看到的值是在另一端复制并接受的值。这就是为什么我将“按价值传递”与所见即所得相关联的原因,因为您所看到的就是所得到的。所有这一切都使您可以编写不隐藏两个功能之间切换成本的代码。这有助于保持良好的心理模型,以了解过渡期间每个函数调用将如何影响程序。

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

清单1:

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程序启动时,运行时将创建主goroutine以开始执行所有代码,包括main函数中的代码。 Gorutin是适合操作系统线程的执行路径,该线程最终运行在某些内核上。从版本1.8开始,每个goroutine都提供了一个初始的连续内存块,大小为2048字节,这形成了堆栈空间。多年来,此初始堆栈大小已更改,并且将来可能会更改。

堆栈很重要,因为它为分配给每个单独功能的帧边界提供了物理存储空间。到主goroutine执行清单1中的main函数时,程序堆栈(非常高级)将如下所示:

图1:



在图1中,您可以看到堆栈的一部分已针对主要功能进行了“框架化”。此部分称为“ 堆栈框架 ”,正是该框架表示堆栈上主要功能的边界。框架被设置为在调用函数时执行的代码的一部分。您还可以看到,count变量的内存已分配给main框架内的0x10429fa4。

还有一个有趣的地方,如图1所示。活动帧下的所有堆栈内存均无效,但活动帧及其后的堆栈内存均有效。您需要清楚地了解堆栈的有效部分和无效部分之间的边界。

地址


变量用于为特定存储单元分配名称,以提高代码的可读性并帮助您了解所使用的数据。如果您有一个变量,那么您在内存中就有一个值,如果您有一个值在内存中,那么它必须有一个地址。在第09行,主函数调用内置的println函数以显示count变量的“值”和“地址”。

清单2:

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

使用&号获取变量位置的地址并不是什么新鲜事,其他语言也使用此运算符。如果在32位体系结构(例如Go Playground)上运行代码,则第09行的输出应类似于以下输出:

清单3:

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

函数调用


接下来,在第12行,主要功能调用了增量功能。

清单4:

12    increment(count)

进行函数调用意味着程序必须在堆栈上创建新的内存部分。但是,一切都有些复杂。为了成功完成一个函数调用,期望在过渡期间跨帧边界传输数据并将其放置在新帧中。特别地,期望在呼叫期间复制并发送整数值。您可以通过查看第18行上的递增函数的声明来了解此需求。

清单5:

18 func increment(inc int) {

如果再次查看对第12行的增量函数的调用,您将看到代码传递了变量计数的“值”。该值将被复制,传输并放置在用于增量功能的新帧中。请记住,增量函数只能在其自己的帧中读写存储器,因此它需要inc变量来获取,存储和访问其自己的已传输计数器值的副本。

在增量函数中的代码开始执行之前,程序堆栈(非常高的层次)将如下所示:

图2:



您会看到堆栈上现在有两帧-一帧为主帧,一帧为增量帧。在增量框架内,您可以看到包含值10的inc变量,该变量在函数调用期间被复制并传递。inc变量地址为0x10429f98,它在内存中较小,因为帧被压入堆栈,这只是实现细节,没有任何意义。重要的是该程序从main框架中检索计数值,并将该值的副本放置在框架中以使用inc变量增加。

其余代码在增量内递增,并显示inc变量的“值”和“地址”。

清单6:

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

操场上第22行的输出应如下所示:

清单7:

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

执行相同的代码行后,堆栈显示如下:

图3:



执行第21和22行之后,递增函数结束,并将控制权返回给主函数。然后,main函数再次在第14行显示局部变量计数的“值”和“地址”。

清单8:

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

操场上程序的完整输出应如下所示:

清单9:

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

main帧中的计数值在调用增量之前和之后是相同的。

从函数返回


当函数退出并且控制权返回到调用函数时,堆栈上的内存实际上发生了什么?简短的答案是什么。返回增量函数后的堆栈如下

所示图4:



堆栈看上去与图3完全相同,只是现在将与增量函数关联的帧视为无效内存。这是因为main的框架现在处于活动状态。为增量功能创建的内存保持不变。

清除返回函数的内存框架将浪费时间,因为尚不清楚是否会再次需要此内存。因此记忆仍然保持原样。在每个函数调用期间,当拍摄一个帧时,将清除该帧的堆栈存储器。这是通过初始化适合框架的任何值来完成的。由于所有值都初始化为它们的``零值'',因此每次函数调用都会正确清除堆栈。

价值共享


如果对于增量函数直接使用main框架中存在的count变量很重要,该怎么办?这是指针的时机到了。指针有一个目的-与函数共享一个值,以便该函数可以读取和写入此值,即使该值不直接存在于其框架中也是如此。

如果您认为不需要“共享”值,则无需使用指针。学习指针时,请务必使用简洁的字典,而不是运算符或语法。请记住,指针旨在共享,并且在阅读代码时,将&运算符替换为短语“ sharing”。

指针类型


对于您声明的或由语言本身直接声明的每种类型,您将获得一个可用于共享的免费指针类型。已经有一个称为int的内置类型,因此有一个名为* int的指针类型。如果声明一个名为User的类型,则免费获得一个名为* User的指针类型。

所有类型的指针都有两个相同的特征。首先,它们以*字符开头。其次,它们在内存中的大小都相同,并且占据4或8个字节的表示形式代表该地址。在32位体系结构(例如,在游乐场)上,指针需要4字节的内存,而在64位体系结构(例如,您的计算机)上,指针则需要8字节的内存。

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

间接内存访问


看一下这个小程序,它通过“按值”传递地址来进行函数调用。这将使用增量函数从主堆栈的堆栈帧中拆分count变量:

清单10:

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行:

清单11:

12    increment(&count)

这次,在第12行,代码不复制“值”并将其传递给count变量,而是传递其“ address”而不是count变量。现在您可以说:“我正在共享”带有函数增量的变量计数。这就是&运算符所说的“共享”。

知道这仍然是“按值传递”,唯一的区别是传递的值是地址,而不是整数。地址也是值;这就是复制并跨过框架边界传递的内容,以调用该函数。

由于地址值已复制并传递,因此您需要在增量框架内添加一个变量来获取和保存该整数地址。第18行上有一个整数指针变量声明。

清单12:

18 func increment(inc *int) {

如果传递了类型为User的值的地址,则必须将变量声明为* User。尽管所有指针变量都存储了地址值,但它们不能传递任何地址,只能传递与指针类型关联的地址。共享值的基本原理是接收函数必须读取或写入该值。您需要有关任何值类型的信息,以便对其进行读写。编译器将确保此函数仅使用与正确的指针类型关联的值。

调用递增函数后,堆栈如下

所示图5:



图5显示了使用地址作为值执行“按值传递”时的堆栈外观。现在,增量功能框架内的指针变量指向位于main框架内的count变量。

现在,使用指针变量,该函数可以对位于main框架内的count变量执行间接读取和更改操作。

清单13:

21    *inc++

这次,*字符充当运算符,并应用于指针变量。使用*作为运算符意味着“指针指向的值”。指针变量提供对使用它的函数框架之外的内存的间接访问。有时,这种间接读取或写入称为指针取消引用。增量函数仍然需要在其框架中具有一个指针变量,可以直接读取该指针变量以执行间接访问。

图6显示了第21行之后的堆栈外观。

图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指针变量的“值”与计数变量的“地址”匹配。这建立了共享关系,允许间接访问框架外的内存。增量函数通过指针写入后,一旦将控制返回给主函数,该更改便对主函数可见。

指针变量并不特殊


指针变量不是特殊的,因为它们是与任何其他变量相同的变量。它们具有内存分配,并且包含含义。碰巧的是,所有指针变量(无论它们可以指向的值的类型)始终具有相同的大小和表示形式。令人困惑的是,*字符充当代码内的运算符,并用于声明指针类型。如果可以将类型声明与指针操作区分开,则可以帮助消除一些混乱。

结论


这篇文章描述了指针的用途,堆栈的操作以及Go中指针的机制。这是理解编写连贯且可读的代码所需的机制,设计原理和使用技术的第一步。

最后,这是您学到的知识:

  • 在帧边界内执行功能,这为每个相应功能提供了单独的存储空间。
  • 调用函数时,会在两个帧之间发生过渡。
  • “按值”数据传输的优点是可读性。
  • 堆栈很重要,因为它为分配给每个单独功能的帧边界提供了物理存储空间。
  • 活动框架下方的所有堆栈内存均无效,但活动框架及其上方的所有堆栈内存均有效。
  • , .
  • , , .
  • — , , .
  • , , , , .
  • - , .
  • - - , , . , .

All Articles