可視化 Go 內存管理
在本章中,我們將研究 Go 編程語言(Golang)[1] 的內存管理。和 C/C++、Rust 等一樣,Go 是一種靜態類型的編譯型語言。因此,Go 不需要 VM,Go 應用程序二進制文件中嵌入了一個小型運行時 (Go runtime),可以處理諸如垃圾收集 (GC),調度和併發之類的語言功能。
Go 內部內存結構
首先,讓我們看看 Go 內部的內存結構是什麼樣子的。
Go 運行時將 Goroutines(G)調度到邏輯處理器(P)上執行。每個 P 都有一臺邏輯機器(M)。在這篇文章中,我們將使用 P、M 和 G。如果您不熟悉 Go 調度程序 [2],請先閱讀《Go 調度程序:Ms,Ps 和 Gs》[3]。
Goroutine 調度原理
每個 Go 程序進程都由操作系統(OS)分配了一些虛擬內存,這是該進程可以訪問的全部內存。在這個虛擬內存中實際正在使用的內存稱爲 Resident Set(駐留內存)。該空間由內部內存結構管理,如下所示:
Go 內部內存結構原理圖
這是一個簡化的視圖,基於 Go 使用的內部對象。實際上,Go 將內存劃分和分組爲頁 (page),就像這篇文章 [4] 描述的那樣。
這與我們在前幾章中看到的 JVM[5] 和 V8[6] 的內存結構完全不同。如您所見,這裏沒有分代內存。這樣做的主要原因是 TCMalloc[7](線程緩存 Malloc),Go 自己的內存分配器正是基於該模型實現的。
讓我們看看 Go 獨特的內存構造是什麼樣子的:
頁堆 page heap(mheap)
這裏是 Go 存儲動態數據(在編譯時無法計算大小的任何數據)的地方。它是最大的內存塊,也是進行垃圾收集(GC)的地方。
駐留內存 (resident set) 被劃分爲每個大小爲 8KB 的頁,並由一個全局 mheap 對象管理。
大對象(大小> 32kb 的對象)直接從 mheap 分配。這些大對象申請請求是以獲取中央鎖 (central lock) 爲代價的,因此在任何給定時間點只能滿足一個 P 的請求。
mheap 通過將頁歸類爲不同結構進行管理的:
- mspan:mspan 是 mheap 中管理的內存頁的最基本結構。這是一個雙向鏈接列表,其中包含起始頁面的地址,span size class 和 span 中的頁面數量。像 TCMalloc 一樣,Go 將內存頁按大小分爲 67 個不同類別,大小從 8 字節到 32KB,如下圖所示
mspan 結構
每個 span 存在兩個,一個 span 用於帶指針的對象(scan class),一個用於無指針的對象(noscan class)。這在 GC 期間有幫助,因爲 noscan 類查找活動對象時無需遍歷 span。
-
mcentral:mcentral 將相同大小級別的 span 歸類在一起。每個 mcentral 包含兩個 mspanList:
-
empty:雙向 span 鏈表,包括沒有空閒對象的 span 或緩存 mcache 中的 span。當此處的 span 被釋放時,它將被移至 non-empty span 鏈表。
-
non-empty:有空閒對象的 span 雙向鏈表。當從 mcentral 請求新的 span,mcentral 將從該鏈表中獲取 span 並將其移入 empty span 鏈表。
如果 mcentral 沒有可用的 span,它將向 mheap 請求新頁。
-
arena:堆在已分配的虛擬內存中根據需要增長和縮小。當需要更多內存時,mheap 從虛擬內存中以每塊 64MB(對於 64 位體系結構)爲單位獲取新內存, 這塊內存被稱爲 arena。這塊內存也會被劃分頁並映射到 span。
-
mcache:這是一個非常有趣的構造。mcache 是提供給 P(邏輯處理器)的高速緩存,用於存儲小對象(對象大小 <= 32Kb)。儘管這類似於線程堆棧,但它是堆的一部分,用於動態數據。所有類大小的 mcache 包含 scan 和 noscan 類型 mspan。Goroutine 可以從 mcache 沒有任何鎖的情況下獲取內存,因爲一次 P 只能有一個鎖 G。因此,這更有效。mcache 從 mcentral 需要時請求新的 span。
棧
這是棧存儲區,每個 Goroutine(G)有一個棧。在這裏存儲了靜態數據,包括函數棧幀,靜態結構,原生類型值和指向動態結構的指針。這與分配給每個 P 的 mcache 不是一回事。
Go 內存使用(棧與堆)
現在我們已經清楚了內存的組織方式,現在讓我們看看程序執行時 Go 是如何使用 Stack 和 Heap 的。
我們使用下面的這個 Go 程序,代碼沒有針對正確性進行優化,因此可以忽略諸如不必要的中間變量之類的問題,因此,重點是可視化棧和堆內存的使用情況。
package main
import "fmt"
type Employee struct {
name string
salary int
sales int
bonus int
}
const BONUS_PERCENTAGE = 10
func getBonusPercentage(salary int) int {
percentage := (salary * BONUS_PERCENTAGE) / 100
return percentage
}
func findEmployeeBonus(salary, noOfSales int) int {
bonusPercentage := getBonusPercentage(salary)
bonus := bonusPercentage * noOfSales
return bonus
}
func main() {
var john = Employee{"John", 5000, 5, 0}
john.bonus = findEmployeeBonus(john.salary, john.sales)
fmt.Println(john.bonus)
}
與許多垃圾回收語言相比,Go 的一個主要區別是許多對象直接在程序棧上分配。Go 編譯器使用一種稱爲 “逃逸分析”[8] 的過程來查找其生命週期在編譯時已知的對象,並將它們分配在棧上,而不是在垃圾回收的堆內存中。
在編譯過程中,Go 進行了逃逸分析,以確定哪些可以放入棧(靜態數據),哪些需要放入堆(動態數據)。我們可以通過運行帶有-gcflags '-m'
標誌的 go build 命令來查看分析的細節。對於上面的代碼,它將輸出如下內容:
❯ go build -gcflags '-m' gc.go
# command-line-arguments
temp/gc.go:14:6: can inline getBonusPercentage
temp/gc.go:19:6: can inline findEmployeeBonus
temp/gc.go:20:39: inlining call to getBonusPercentage
temp/gc.go:27:32: inlining call to findEmployeeBonus
temp/gc.go:27:32: inlining call to getBonusPercentage
temp/gc.go:28:13: inlining call to fmt.Println
temp/gc.go:28:18: john.bonus escapes to heap
temp/gc.go:28:13: io.Writer(os.Stdout) escapes to heap
temp/gc.go:28:13: main []interface {} literal does not escape
<autogenerated>:1: os.(*File).close .this does not escape
讓我們將其可視化。單擊下方圖片下載幻燈片,然後翻閱幻燈片,以查看上述程序是如何執行的以及如何使用棧和堆存儲器的:
可視化程序執行過程中棧和堆的使用
正如你看到的:
-
main 函數被保存棧中的 “main 棧幀” 中
-
每個函數調用都作爲一個棧幀塊被添加到棧中
-
包括參數和返回值在內的所有靜態變量都保存在函數的棧幀塊內
-
無論類型如何,所有靜態值都直接存儲在棧中。這也適用於全局範疇
-
所有動態類型都在堆上創建,並且被棧上的指針所引用。小於 32Kb 的對象由 P 的 mcache 分配。這同樣適用於全局範疇
-
具有靜態數據的結構體保留在棧上,直到在該位置將任何動態值添加到該結構中爲止。該結構被移到堆上。
-
從當前函數調用的函數被推入堆頂部
-
當函數返回時,其棧幀將從棧中刪除
-
一旦主過程 (main) 完成,堆上的對象將不再具有來自 Stack 的指針的引用,併成爲孤立對象
您可以看到,棧是由操作系統自動管理的,而不是 Go 本身。因此,我們不必擔心棧。另一方面,堆並不是由操作系統自動管理的,並且由於其具有最大的內存空間並保存動態數據,因此它可能會成倍增長,從而導致我們的程序隨着時間耗盡內存。隨着時間的流逝,它也變得支離破碎,使應用程序變慢。解決這些問題是垃圾收集的初衷。
Go 內存管理
Go 的內存管理包括在需要內存時自動分配內存,在不再需要內存時進行垃圾回收。這是由標準庫完成的 (譯註:應該是運行時完成的)。與 C/C++ 不同,開發人員不必處理它,並且 Go 進行的基礎管理得到了高效的優化。
內存分配
許多采用垃圾收集的編程語言都使用分代內存結構來使收集高效,同時進行壓縮以減少碎片。正如我們前面所看到的,Go 在這裏採用了不同的方法,Go 在構造內存方面有很大的不同。
Go 使用線程本地緩存 (thread local cache) 來加速小對象分配,並維護着 scan/noscan 的 span 來加速 GC。這種結構以及整個過程避免了碎片,從而在 GC 期間無需做緊縮處理。讓我們看看這種分配是如何發生的。
Go 根據對象的大小決定對象的分配過程,分爲三類:
微小對象 (Tiny)(size <16B):使用 mcache 的微小分配器分配大小小於 16 個字節的對象。這是高效的,並且在單個 16 字節塊上可完成多個微小分配。
微小分配
小對象(尺寸 16B〜32KB):大小在 16 個字節和 32k 字節之間的對象被分配在 G 運行所在的 P 的 mcache 的對應的 mspan size class 上。
小對象分配
在微小型和小型對象分配中,如果 mspan 的列表爲空,分配器將從 mheap 獲取大量的頁面用於 mspan。如果 mheap 爲空或沒有足夠大的頁面滿足分配請求,那麼它將從操作系統中分配一組新的頁(至少 1MB)。
大對象(大小 > 32KB):大於 32 KB 的對象直接分配在 mheap 的相應大小類上 (size class)。如果 mheap 爲空或沒有足夠大的頁面滿足分配請求,則它將從操作系統中分配一組新的頁(至少 1MB)。
大對象分配
注意:您可以在此處 [9] 找到以幻燈片形式記錄的 GIF 圖像
垃圾收集 (GC)
現在我們知道 Go 如何分配內存了,讓我們再看看它是如何自動回收堆內存的,這對於應用程序的性能非常重要。當程序嘗試在堆上分配的內存大於可用內存時,我們會遇到內存不足的錯誤 (out of memory)。不當的堆內存管理也可能導致內存泄漏。
Go 通過垃圾回收機制管理堆內存。簡單來說,它釋放了孤兒對象 (orphan object) 使用的內存,所謂孤兒對象是指那些不再被棧直接或間接(通過另一個對象中的引用)引用的對象,從而爲創建新對象的分配騰出了空間。
從 Go 1.12 版本 [10] 開始,Go 使用了非分代的、併發的、基於三色標記和清除的垃圾回收器。收集過程大致如下所示,由於版本之間的差異,我不想做細節的描述。但是,如果您對此感興趣,那麼我推薦這個很棒的系列文章 [11]。
當完成一定百分比(GC 百分比)的堆分配,GC 過程就開始了。收集器將在不同工作階段執行不同的工作:
-
標記設置(mark setup, stw):GC 啓動時,收集器將打開寫屏障 (write barrier),以便可以在下一個併發階段維護數據完整性。此步驟需要非常小的暫停 (stw),因此每個正在運行的 Goroutine 都會暫停以啓用此功能,然後繼續。
-
標記(併發執行的):打開寫屏障後,實際的標記過程將並行啓動,這個過程將使用可用 CPU 能力的 25%。對應的 P 將保留,直到該標記過程完成。這個過程是使用專用的 Goroutines 完成的。在這個過程中,GC 標記了堆中的活動對象 (被任何活動的 Goroutine 的棧中引用的)。當採集花費更長的時間時,該過程可以從應用程序中徵用活動的 Goroutine 來輔助標記過程。這稱爲 Mark Assist。
-
標記終止(stw):標記一旦完成,每個活動的 Goroutine 都會暫停,寫入屏障將關閉,清理任務將開始執行。GC 還會在此處計算下一個 GC 目標。完成此操作後,保留的 P 的會釋放回應用程序。
-
清除(併發):當完成收集並嘗試分配後,清除過程開始將未標記爲活動的對象回收。清除的內存量與分配的內存量是同步的 (即回收後的內存馬上可以被再分配了)。
讓我們在一個 Goroutine 中看看這個過程。爲了簡潔起見,將對象的數量保持較小。單擊下面圖片,可下載幻燈片,然後翻閱幻燈片查看該過程:
-
我們以一個 Goroutine 爲例,實際過程是對所有活動 Goroutine 都進行的。首先打開寫屏障。
-
標記過程選擇 GC root 並將其着色爲黑色,並以深度優先的樹狀方式遍歷該該根節點裏面的指針,將遇到的每個對象都標記爲灰色
-
當它到達 noscan span 中的某個對象或某個對象不再有指針時,它完成了這個根節點的標記操作並選取下一個 GC root 對象
-
當掃描完所有 GC root 節點之後,它將選取灰色對象,並以類似方式繼續遍歷其指針
-
如果在打開寫屏障時,指向對象的指針發生任何變化,則該對象將變爲灰色,以便 GC 對其進行重新掃描
-
當不再有灰色對象留下時,標記過程完成,並且寫屏障被關閉
-
當分配開始時 (因爲寫屏障關閉了),清除過程也會同步進行
我們看到這裏有一些停止世界 (stop) 的過程,但是通常這個過程非常快,在大多數情況下可以忽略不計。對象的着色在 span 的 gcmarkBits 屬性中進行。
結論
這篇文章爲您提供了 Go 內存結構和內存管理的概述。這裏不是全面詳盡的說明,有許多更高級的概念,實現細節在各個版本之間都在不斷變化。但是對於大多數 Go 開發人員來說,這些信息就已經足夠了,我希望它能幫助您編寫出更好的、性能更高的應用程序,牢記這些,將有助於您避免下一個內存泄漏問題。
參考資料
[1]
Go 編程語言(Golang): https://tonybai.com/tag/go
[2]
Go 調度程序: https://tonybai.com/2017/11/23/the-simple-analysis-of-goroutine-schedule-examples
[3]
《Go 調度程序:Ms,Ps 和 Gs》: https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part2.html
[4]
這篇文章: https://tonybai.com/2020/02/20/a-visual-guide-to-golang-memory-allocator-from-ground-up
[5]
JVM: https://deepu.tech/memory-management-in-jvm/
[6]
V8: https://deepu.tech/memory-management-in-v8/
[7]
TCMalloc: http://goog-perftools.sourceforge.net/doc/tcmalloc.html
[8]
“逃逸分析”: https://www.ardanlabs.com/blog/2017/05/language-mechanics-on-escape-analysis.html
[9]
此處: https://speakerdeck.com/deepu105/go-memory-allocation
[10]
Go 1.12 版本: https://tonybai.com/2019/03/02/some-changes-in-go-1-12/
[11]
系列文章: https://www.ardanlabs.com/blog/2018/12/garbage-collection-in-go-part1-semantics.html
轉自:
https://juejin.cn/post/7107533102083211301
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/OSJy9mH7c09yLPiGa48vaw