『每周譯Go』Go 內(nèi)存管理概述
原文地址:https://medium.com/safetycultureengineering/an-overview-of-memory-management-in-go-9a72ec7c76a8
原文作者:Scott Gangemi
本文永久鏈接:https://github.com/gocn/translator/blob/master/2021/w21_An_overview_of_memory_management_in_Go.md
譯者:haoheipi
校對(duì):cvley、fivezh
隨著程序的運(yùn)行,對(duì)象被寫入內(nèi)存。在一些特定時(shí)刻當(dāng)它們不再被需要時(shí),它們應(yīng)該被移除。這個(gè)過程被稱為 內(nèi)存管理 。本文旨在給出內(nèi)存管理的概述,然后深入研究在 Go 中如何使用垃圾收集器實(shí)現(xiàn)內(nèi)存管理。Go 的內(nèi)存管理近些年已經(jīng)發(fā)生了很大變化,未來很可能還會(huì)發(fā)生更多變化。如果您正在閱讀這篇文章,并且您使用的是比 1.16 更高的 Go 版本,那么這里的一些信息可能已經(jīng)過時(shí)了。
手動(dòng)內(nèi)存管理
在像 C 這樣的編程語言中,程序員會(huì)調(diào)用 malloc 或 calloc 之類的函數(shù)來將對(duì)象寫入內(nèi)存。這些函數(shù)返回一個(gè)指針,指向該對(duì)象在堆內(nèi)存中的位置。當(dāng)這個(gè)對(duì)象不再被需要時(shí),程序員調(diào)用 free 函數(shù)來再釋放以便再次使用這塊內(nèi)存。這種內(nèi)存管理的方法被稱為 顯式釋放 。它非常的強(qiáng)大,使程序員能夠更好地控制正在使用的內(nèi)存,從而允許某些類型優(yōu)化變得更加容易,特別是在小內(nèi)存環(huán)境下。但是,它也會(huì)導(dǎo)致兩種類型的編程錯(cuò)誤。
第一種是提前調(diào)用 free ,這會(huì)創(chuàng)建一個(gè) 懸空指針 。懸空指針是指不再指向內(nèi)存中有效對(duì)象的指針。那么這會(huì)非常糟糕,因?yàn)槌绦蚱谕粋€(gè)指針指向的是已定義的值,而當(dāng)這個(gè)懸空指針稍后被訪問時(shí),并不能保證在內(nèi)存中該位置存在什么值。可能什么都沒有,或者完全是其他值。第二種錯(cuò)誤,內(nèi)存根本無法釋放。如果程序員忘記釋放一個(gè)對(duì)象,他們可能會(huì)面臨 內(nèi)存泄漏 風(fēng)險(xiǎn),因?yàn)閮?nèi)存會(huì)被越來越多的對(duì)象填滿。如果內(nèi)存不足,這可能導(dǎo)致程序變慢或崩潰。所以當(dāng)不得不顯式地管理內(nèi)存時(shí),可能會(huì)在程序中引入不可預(yù)測(cè)的錯(cuò)誤。
自動(dòng)內(nèi)存管理
這是像 Go 這樣的語言提供了 自動(dòng)的動(dòng)態(tài)內(nèi)存管理 ,或者更簡(jiǎn)單地說,垃圾收集 的原因。具有垃圾收集功能的語言提供了如下好處:
安全性的提高 更好的跨操作系統(tǒng)移植性 需要編寫的代碼更少 代碼的運(yùn)行時(shí)校驗(yàn) 數(shù)組的邊界檢查
確實(shí)垃圾收集會(huì)帶來性能開銷,但并不像通常認(rèn)為的那樣多。所以折衷的方案是,程序員專注于他們程序的業(yè)務(wù)邏輯,并確保它符合目標(biāo),而不用擔(dān)心管理內(nèi)存。
一個(gè)正在運(yùn)行的程序?qū)?duì)象存儲(chǔ)在內(nèi)存中的兩個(gè)位置, 堆 和 棧 。垃圾收集作用于堆上,而不是棧。棧是一個(gè)存儲(chǔ)函數(shù)值的后進(jìn)先出數(shù)據(jù)結(jié)構(gòu)。從函數(shù)內(nèi)部調(diào)用另一個(gè)函數(shù),會(huì)將一個(gè)新的 棧幀 放到棧上,它包含被調(diào)用函數(shù)的值等。當(dāng)函數(shù)調(diào)用返回時(shí),它的棧楨將會(huì)從棧上彈出。當(dāng)在調(diào)試一個(gè)崩潰的程序時(shí),您可能會(huì)熟悉棧這一結(jié)構(gòu)。大多數(shù)語言的編譯器會(huì)返回一個(gè)調(diào)用棧來幫助跟蹤調(diào)試,它會(huì)顯示在這一點(diǎn)之前被調(diào)用的函數(shù)。

棧可以以一種后進(jìn)先出的方式將值 “推” 到頂部,或者從頂部 “彈出” 。圖片來源 Wikipedia.
與棧相反,堆中包含的是在函數(shù)外部被引用的值。例如,在程序開始時(shí)定義的靜態(tài)常量,或更復(fù)雜的對(duì)象,如 Go 結(jié)構(gòu)體。當(dāng)程序員定義一個(gè)放置在堆上的對(duì)象時(shí),將分配所需的內(nèi)存大小,并返回指向該對(duì)象的指針。堆是一種圖結(jié)構(gòu),對(duì)象代表著節(jié)點(diǎn),這些節(jié)點(diǎn)被代碼或者其他對(duì)象所引用。隨著程序的運(yùn)行,堆將隨著對(duì)象的添加而繼續(xù)增長(zhǎng),除非對(duì)堆做清理。

堆從根節(jié)點(diǎn)開始,隨著更多的對(duì)象被添加而增長(zhǎng)。
Go 中的垃圾收集
Go 更喜歡在棧上分配內(nèi)存,所以大部分內(nèi)存分配都會(huì)在這里結(jié)束。這也意味著 Go 中每個(gè) goroutine 都有一個(gè)棧,如果可能的話,Go 將分配變量在這個(gè)棧上。Go 編譯器通過執(zhí)行 逃逸分析 來檢查一個(gè)對(duì)象是否 ”逃逸” 出函數(shù)內(nèi)部,從而嘗試證明一個(gè)變量在函數(shù)之外不被需要。如果編譯器可以確定一個(gè)變量的 生命周期,它將被分配在棧上。但是,如果變量的生存期不確定,它將會(huì)被分配到堆上。通常,如果一個(gè) Go 程序有一個(gè)指向?qū)ο蟮闹羔槪敲丛搶?duì)象就被存儲(chǔ)在堆上。看看下面的示例代碼:
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
}
出于本例的目的,讓我們假設(shè)這是一個(gè)正在運(yùn)行的程序的一部分,因?yàn)槿绻@是整個(gè)程序,那么 Go 編譯器會(huì)通過將變量分配到棧來優(yōu)化它。當(dāng)程序運(yùn)行時(shí):
testStruct是被定義和放置在堆中的一個(gè)可用內(nèi)存塊myFunction函數(shù)被調(diào)用執(zhí)行時(shí)將會(huì)分配一個(gè)棧。testVar1和testVar2都被存儲(chǔ)在這個(gè)棧上。調(diào)用 addTwoNumbers時(shí),一個(gè)新的棧幀被推到棧上,并帶有函數(shù)的兩個(gè)參數(shù)。當(dāng) addTwoNumbers完成執(zhí)行,它的結(jié)果返回給myFunction 并且addTwoNumbers的棧幀從棧中彈出,因?yàn)樗辉俦恍枰?/section>指向 testStruct的指針被跟隨到它堆上的位置,并且value字段被更新。myFunction退出,并清除為它創(chuàng)建的棧。testStruct的值繼續(xù)保持在堆上,直到垃圾收集發(fā)生。
testStruct 現(xiàn)在在堆上,也沒有使用,Go 運(yùn)行時(shí)也不知道是否仍然需要它。為此,Go 依賴于一個(gè)垃圾收集器。垃圾收集器有兩個(gè)關(guān)鍵部分,一個(gè) 更改器 和一個(gè) 收集器。收集器執(zhí)行垃圾收集邏輯并找到應(yīng)該釋放其內(nèi)存的對(duì)象。更改器執(zhí)行應(yīng)用程序代碼并將新對(duì)象分配給堆。它還在程序運(yùn)行時(shí)更新堆上的現(xiàn)有對(duì)象,包括使不再需要的某些對(duì)象變?yōu)椴豢蛇_(dá)。

由于更改器所做的更改,底部的對(duì)象已變?yōu)椴豢稍L問。它應(yīng)該由垃圾收集器清理。
Go 垃圾收集器的實(shí)現(xiàn)
Go 的垃圾收集器是一個(gè) 非分代,并發(fā),三色標(biāo)記清除的垃圾收集器。讓我們把這幾項(xiàng)分解。
分代假設(shè) 是壽命短的對(duì)象(如臨時(shí)變量)最常被回收。因此,分代垃圾收集器主要關(guān)注最近分配的對(duì)象。然而如前所述,編譯器優(yōu)化允許 Go 編譯器將具有已知生命周期的對(duì)象分配在棧上。這意味著堆上的對(duì)象更少,因此垃圾收集的對(duì)象更少。這也意味著在 Go 中不需要分代垃圾收集器。因此,Go 使用了一個(gè)非分代的垃圾收集器。并發(fā)意味著收集器與更改器線程同時(shí)運(yùn)行。因此,Go 使用的是一個(gè)非分代、并發(fā)的垃圾收集器。標(biāo)記清除是垃圾收集器的工作類型,三色是用于實(shí)現(xiàn)這一功能的算法。
一個(gè)標(biāo)記清除垃圾收集器有兩個(gè)階段,不出所料地命名為 標(biāo)記 和 清除 。在標(biāo)記階段,收集器遍歷堆并標(biāo)記不再需要的對(duì)象。后續(xù)掃描階段將刪除這些對(duì)象。標(biāo)記和清除是一種間接算法,因?yàn)樗鼧?biāo)記活動(dòng)對(duì)象,并移除其他所有東西。
原圖地址:https://github.com/gocn/translator/raw/master/static/images/w21_An_overview_of_memory_management_in_Go/figure4.gif
可視化的標(biāo)記清除收集器過程,來源于這里。如果你感興趣的話,還可以看到其他類型的垃圾收集器。
Go 用幾個(gè)步驟實(shí)現(xiàn)了這一點(diǎn):
Go 讓所有的 goroutines 到達(dá)一個(gè)垃圾收集安全點(diǎn),并使用一個(gè)名為 stop the world 的過程。這將暫時(shí)停止程序的運(yùn)行,并打開一個(gè) 寫屏障 以維護(hù)堆上的數(shù)據(jù)完整性。通過允許 goroutine 和收集器同時(shí)運(yùn)行,從而實(shí)現(xiàn)了并發(fā)性。
一旦所有的 goroutine 都打開了寫障礙,Go 運(yùn)行 starts the world 并讓工作線程開始執(zhí)行垃圾收集工作。
標(biāo)記是通過使用一個(gè) 三色算法 實(shí)現(xiàn)的。當(dāng)標(biāo)記開始時(shí),除了根對(duì)象是灰色的,所有對(duì)象都是白色的。根是所有其他堆對(duì)象的來源,并作為運(yùn)行程序的一部分實(shí)例化。垃圾收集器首先掃描棧、全局變量和堆指針,以了解什么對(duì)象正在使用。當(dāng)掃描一個(gè)棧時(shí),工作線程將停止 goroutine ,并通過從根向下遍歷將所有發(fā)現(xiàn)的對(duì)象標(biāo)記為灰色。然后繼續(xù)執(zhí)行 goroutine 。
然后,灰色的對(duì)象將入隊(duì)變成黑色,這表明它們?nèi)栽谑褂弥小R坏┧械幕疑珜?duì)象被標(biāo)為黑色,收集器將會(huì)再一次 stop the world 并且清理所有不再被需要的白色節(jié)點(diǎn)對(duì)象。程序現(xiàn)在可以繼續(xù)運(yùn)行,直到它需要再次清理更多內(nèi)存。

這張來自維基百科的圖表讓上述更容易理解。顏色有點(diǎn)混亂,但白色物體是淺灰色,灰色物體是黃色,黑色物體是藍(lán)色。
一旦程序按照使用的內(nèi)存比例分配了額外的內(nèi)存,這個(gè)進(jìn)程將再次啟動(dòng)。GOGC 環(huán)境變量決定了這一比例,默認(rèn)值為 100 。Go 的源代碼描述如下:
如果 GOGC=100 并且我們正在使用 4M 內(nèi)存,我們將在到達(dá) 8M 時(shí)再次進(jìn)行 GC(這個(gè)標(biāo)記在 next_gc 變量中被跟蹤)。這使 GC 成本與分配成本成線性比例。調(diào)整 GOGC 只是改變線性常數(shù)(還有額外內(nèi)存的使用量)。
Go 的垃圾收集器通過將內(nèi)存管理抽象到 Go 運(yùn)行時(shí)來提高效率,這也是使 Go 具有如此優(yōu)秀性能的原因之一。Go 內(nèi)置的工具允許您優(yōu)化程序中垃圾收集的觸發(fā)行為,如果您感興趣,可以對(duì)此進(jìn)行研究。至此,我希望您了解到了更多關(guān)于垃圾收集的工作原理和在 Go 中如何實(shí)現(xiàn)垃圾收集的知識(shí)。
參考
Garbage Collection in Go: Part 1
Getting to Go: The Journey of Go’s Garbage Collector
Go: How Does the Garbage Collector Mark the Memory?
Golang: Cost of using the heap
Golang FAQ
Google Groups discussion, comment by Ian Lance Taylor
Implementing memory management with Golang’s garbage collector
Memory Management Reference
Stack (abstract data type)
The Garbage Collection Handbook
Tracing garbage collection: Tri-color marking
別忘了還有 Gopher China 2021 大會(huì)在文末等著你哦~
想和各位技術(shù)大佬們同臺(tái)見面嘛?
那就趕快點(diǎn)擊下方「閱讀原文」報(bào)名參加呀!
