首頁 >後端開發 >Golang >Go 語言機制之堆疊與指針

Go 語言機制之堆疊與指針

Go语言进阶学习
Go语言进阶学习轉載
2023-07-24 16:10:54827瀏覽

這個系列包含四篇文章,主要講解 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() 函數的時候,堆疊空間的分佈如下圖這樣:

Go 語言機制之堆疊與指針

圖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() 函数内部代码开始执行之前,协程的栈(站在一个非常高的角度)应该是像下图这样的:

Go 語言機制之堆疊與指針

图 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 ]

执行这些代码之后,栈就会像下面这样:

Go 語言機制之堆疊與指針

图 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() 函数返回时,栈上的空间看起来像下面这样:

Go 語言機制之堆疊與指針

圖 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() 函数时候,栈空间就像下面这样:

Go 語言機制之堆疊與指針

图 5

当一个地址作为值执行按值传递之后,你可以从图 5 看出栈是如何分布的。现在,increment() 函数帧空间里面的指针变量指向 count 变量,该变量在 main() 函数的帧空间里。

通过使用指针变量,increment() 函数可以间接对 count 变量执行读写操作。

清单 13

*inc++

这一次,字符 * 充当操作符,与指针变量搭配使用。使用 * 操作符是“获取指针指向的值”的意思。指针变量允许在帧外对函数帧内的内存进行间接访问。有时候,间接的读写操作也称为解引用。increment() 函数必须有指针变量,才能够对其他函数帧空间执行间接访问。

执行第 21 行代码之后,栈空间分布如图 6 所示。

Go 語言機制之堆疊與指針

图 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.活動幀以下的所有堆疊記憶體是不可存取的物理記憶體空間;5.活動幀以下的所有堆疊記憶體是不可用的,只有活動幀及其上方的棧內存是有用的;6.呼叫函數意味著協程會在棧內存上開闢一塊新的棧幀;7.每次函數呼叫的時候,當使用到幀時,對應的堆疊記憶體會被初始化;8.設計指標的目的是實現函數間值共享,即使該值不在函數自己棧幀裡,也能對其進行讀寫操作;9.對於每一種類型,不管是自己定義的還是Go 語言內建的,都有對應的指標類型;10.透過使用指標變量,允許在函數幀外進行間接記憶體存取;

11.###與其他變數相比,指標變數並沒有特別之處,因為它們也是變量,有記憶體位址和值。 ##########

以上是Go 語言機制之堆疊與指針的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:Go语言进阶学习。如有侵權,請聯絡admin@php.cn刪除