Go 內存管理概述

隨着程序的運行,對象被寫入內存。在一些特定時刻當它們不再被需要時,它們應該被移除。這個過程被稱爲 內存管理 。本文旨在給出內存管理的概述,然後深入研究在 Go 中如何使用垃圾收集器實現內存管理。Go 的內存管理近些年已經發生了很大變化,未來很可能還會發生更多變化。如果您正在閱讀這篇文章,並且您使用的是比 1.16 更高的 Go 版本,那麼這裏的一些信息可能已經過時了。

手動內存管理

在像 C 這樣的編程語言中,程序員會調用 malloccalloc 之類的函數來將對象寫入內存。這些函數返回一個指針,指向該對象在堆內存中的位置。當這個對象不再被需要時,程序員調用 free 函數來再釋放以便再次使用這塊內存。這種內存管理的方法被稱爲 顯式釋放 。它非常的強大,使程序員能夠更好地控制正在使用的內存,從而允許某些類型優化變得更加容易,特別是在小內存環境下。但是,它也會導致兩種類型的編程錯誤。

第一種是提前調用 free ,這會創建一個 懸空指針 。懸空指針是指不再指向內存中有效對象的指針。那麼這會非常糟糕,因爲程序期望一個指針指向的是已定義的值,而當這個懸空指針稍後被訪問時,並不能保證在內存中該位置存在什麼值。可能什麼都沒有,或者完全是其他值。第二種錯誤,內存根本無法釋放。如果程序員忘記釋放一個對象,他們可能會面臨 內存泄漏 風險,因爲內存會被越來越多的對象填滿。如果內存不足,這可能導致程序變慢或崩潰。所以當不得不顯式地管理內存時,可能會在程序中引入不可預測的錯誤。

自動內存管理

這是像 Go 這樣的語言提供了 自動的動態內存管理 ,或者更簡單地說,垃圾收集 的原因。具有垃圾收集功能的語言提供瞭如下好處:

確實垃圾收集會帶來性能開銷,但並不像通常認爲的那樣多。所以折衷的方案是,程序員專注於他們程序的業務邏輯,並確保它符合目標,而不用擔心管理內存。

一個正在運行的程序將對象存儲在內存中的兩個位置, 堆 和 棧 。垃圾收集作用於堆上,而不是棧。棧是一個存儲函數值的後進先出數據結構。從函數內部調用另一個函數,會將一個新的 棧幀 放到棧上,它包含被調用函數的值等。當函數調用返回時,它的棧楨將會從棧上彈出。當在調試一個崩潰的程序時,您可能會熟悉棧這一結構。大多數語言的編譯器會返回一個調用棧來幫助跟蹤調試,它會顯示在這一點之前被調用的函數。

棧可以以一種後進先出的方式將值 “推” 到頂部,或者從頂部 “彈出” 。圖片來源 Wikipedia.

與棧相反,堆中包含的是在函數外部被引用的值。例如,在程序開始時定義的靜態常量,或更復雜的對象,如 Go 結構體。當程序員定義一個放置在堆上的對象時,將分配所需的內存大小,並返回指向該對象的指針。堆是一種圖結構,對象代表着節點,這些節點被代碼或者其他對象所引用。隨着程序的運行,堆將隨着對象的添加而繼續增長,除非對堆做清理。

堆從根節點開始,隨着更多的對象被添加而增長。

Go 中的垃圾收集

Go 更喜歡在棧上分配內存,所以大部分內存分配都會在這裏結束。這也意味着 Go 中每個 goroutine 都有一個棧,如果可能的話,Go 將分配變量在這個棧上。Go 編譯器通過執行 逃逸分析 來檢查一個對象是否 ” 逃逸” 出函數內部,從而嘗試證明一個變量在函數之外不被需要。如果編譯器可以確定一個變量的 生命週期,它將被分配在棧上。但是,如果變量的生存期不確定,它將會被分配到堆上。通常,如果一個 Go 程序有一個指向對象的指針,那麼該對象就被存儲在堆上。看看下面的示例代碼:

type myStruct struct {
  value int
}
var testStruct = myStruct{value: 0}
func addTwoNumbers(a int, b int) int {
  return a + b
}
func myFunction() {
  testVar1 := 123
  testVar2 := 456
  testStruct.value = addTwoNumbers(testVar1, testVar2)
}
func someOtherFunction() {
  // some other code
  myFunction()
  // some more code
}

出於本例的目的,讓我們假設這是一個正在運行的程序的一部分,因爲如果這是整個程序,那麼 Go 編譯器會通過將變量分配到棧來優化它。當程序運行時:

  1. testStruct 是被定義和放置在堆中的一個可用內存塊

  2. myFunction 函數被調用執行時將會分配一個棧。testVar1testVar2 都被存儲在這個棧上。

  3. 調用 addTwoNumbers 時,一個新的棧幀被推到棧上,並帶有函數的兩個參數。

  4. addTwoNumbers 完成執行,它的結果返回給 myFunction 並且 addTwoNumbers 的棧幀從棧中彈出,因爲它不再被需要。

  5. 指向 testStruct 的指針被跟隨到它堆上的位置,並且 value 字段被更新。

  6. myFunction 退出,並清除爲它創建的棧。testStruct 的值繼續保持在堆上,直到垃圾收集發生。

testStruct 現在在堆上,也沒有使用,Go 運行時也不知道是否仍然需要它。爲此,Go 依賴於一個垃圾收集器。垃圾收集器有兩個關鍵部分,一個 更改器 和一個 收集器。收集器執行垃圾收集邏輯並找到應該釋放其內存的對象。更改器執行應用程序代碼並將新對象分配給堆。它還在程序運行時更新堆上的現有對象,包括使不再需要的某些對象變爲不可達。

由於更改器所做的更改,底部的對象已變爲不可訪問。它應該由垃圾收集器清理。

Go 垃圾收集器的實現

Go 的垃圾收集器是一個 非分代併發三色標記清除的垃圾收集器。讓我們把這幾項分解。

分代假設 是壽命短的對象(如臨時變量)最常被回收。因此,分代垃圾收集器主要關注最近分配的對象。然而如前所述,編譯器優化允許 Go 編譯器將具有已知生命週期的對象分配在棧上。這意味着堆上的對象更少,因此垃圾收集的對象更少。這也意味着在 Go 中不需要分代垃圾收集器。因此,Go 使用了一個非分代的垃圾收集器。併發意味着收集器與更改器線程同時運行。因此,Go 使用的是一個非分代、併發的垃圾收集器。標記清除是垃圾收集器的工作類型,三色是用於實現這一功能的算法。

一個標記清除垃圾收集器有兩個階段,不出所料地命名爲 標記清除 。在標記階段,收集器遍歷堆並標記不再需要的對象。後續掃描階段將刪除這些對象。標記和清除是一種間接算法,因爲它標記活動對象,並移除其他所有東西。

原圖地址:https://github.com/gocn/translator/raw/master/static/images/w21_An_overview_of_memory_management_in_Go/figure4.gif

可視化的標記清除收集器過程,來源於這裏。如果你感興趣的話,還可以看到其他類型的垃圾收集器。

Go 用幾個步驟實現了這一點:

Go 讓所有的 goroutines 到達一個垃圾收集安全點,並使用一個名爲 stop the world 的過程。這將暫時停止程序的運行,並打開一個 寫屏障 以維護堆上的數據完整性。通過允許 goroutine 和收集器同時運行,從而實現了併發性。

一旦所有的 goroutine 都打開了寫障礙,Go 運行 starts the world 並讓工作線程開始執行垃圾收集工作。

標記是通過使用一個 三色算法 實現的。當標記開始時,除了根對象是灰色的,所有對象都是白色的。根是所有其他堆對象的來源,並作爲運行程序的一部分實例化。垃圾收集器首先掃描棧、全局變量和堆指針,以瞭解什麼對象正在使用。當掃描一個棧時,工作線程將停止 goroutine ,並通過從根向下遍歷將所有發現的對象標記爲灰色。然後繼續執行 goroutine 。

然後,灰色的對象將入隊變成黑色,這表明它們仍在使用中。一旦所有的灰色對象被標爲黑色,收集器將會再一次 stop the world 並且清理所有不再被需要的白色節點對象。程序現在可以繼續運行,直到它需要再次清理更多內存。

這張來自維基百科的圖表讓上述更容易理解。顏色有點混亂,但白色物體是淺灰色,灰色物體是黃色,黑色物體是藍色。

一旦程序按照使用的內存比例分配了額外的內存,這個進程將再次啓動。GOGC 環境變量決定了這一比例,默認值爲 100 。Go 的源代碼描述如下:

如果 GOGC=100 並且我們正在使用 4M 內存,我們將在到達 8M 時再次進行 GC(這個標記在 next_gc 變量中被跟蹤)。這使 GC 成本與分配成本成線性比例。調整 GOGC 只是改變線性常數(還有額外內存的使用量)。

Go 的垃圾收集器通過將內存管理抽象到 Go 運行時來提高效率,這也是使 Go 具有如此優秀性能的原因之一。Go 內置的工具允許您優化程序中垃圾收集的觸發行爲,如果您感興趣,可以對此進行研究。至此,我希望您瞭解到了更多關於垃圾收集的工作原理和在 Go 中如何實現垃圾收集的知識。

參考

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/PNRhtdS_gZVTtrkkRmx7yA