可視化 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 結構

每個 span 存在兩個,一個 span 用於帶指針的對象(scan class),一個用於無指針的對象(noscan class)。這在 GC 期間有幫助,因爲 noscan 類查找活動對象時無需遍歷 span。

如果 mcentral 沒有可用的 span,它將向 mheap 請求新頁。

這是棧存儲區,每個 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

讓我們將其可視化。單擊下方圖片下載幻燈片,然後翻閱幻燈片,以查看上述程序是如何執行的以及如何使用棧和堆存儲器的:

可視化程序執行過程中棧和堆的使用

正如你看到的:

您可以看到,棧是由操作系統自動管理的,而不是 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 過程就開始了。收集器將在不同工作階段執行不同的工作:

讓我們在一個 Goroutine 中看看這個過程。爲了簡潔起見,將對象的數量保持較小。單擊下面圖片,可下載幻燈片,然後翻閱幻燈片查看該過程:

我們看到這裏有一些停止世界 (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