Go 垃圾回收指南

引言

本指南旨在幫助高階 Go 使用者透過深入瞭解 Go 垃圾回收器來更好地理解其應用程式的成本。它還提供了關於 Go 使用者如何利用這些見解來提高應用程式資源利用率的指導。本指南不假設您瞭解垃圾回收的任何知識,但假設您熟悉 Go 程式語言。

Go 語言負責安排 Go 值的儲存;在大多數情況下,Go 開發者無需關心這些值儲存在哪裡,或者是否儲存,以及為什麼。然而,實際上,這些值通常需要儲存在計算機的物理記憶體中,而物理記憶體是有限的資源。由於它是有限的,因此必須仔細管理和回收記憶體,以避免在執行 Go 程式時記憶體耗盡。Go 實現的任務是根據需要分配和回收記憶體。

自動回收記憶體的另一個術語是垃圾回收。從高層次來看,垃圾回收器(簡稱 GC)是一個系統,它透過識別記憶體中不再需要的部分來代表應用程式回收記憶體。Go 標準工具鏈提供了一個隨每個應用程式一起釋出的執行時庫,這個執行時庫包含了垃圾回收器。

請注意,本文件所述的垃圾回收器的存在並非由Go 規範保證,Go 規範只保證 Go 值的基礎儲存由語言本身管理。這種省略是故意的,並支援使用截然不同的記憶體管理技術。

因此,本指南是關於 Go 程式語言的特定實現,並且可能不適用於其他實現。具體來說,本指南適用於標準工具鏈(gc Go 編譯器和工具)。Gccgo 和 Gollvm 都使用了非常相似的 GC 實現,因此許多相同的概念適用,但細節可能有所不同。

此外,這是一份動態文件,會隨著時間推移而變化,以最佳地反映 Go 的最新版本。本文件目前描述的是 Go 1.19 版本中的垃圾回收器。

Go 值儲存在哪裡

在深入探討 GC 之前,讓我們先討論一下不需要由 GC 管理的記憶體。

例如,儲存在區域性變數中的非指標 Go 值很可能完全不會由 Go GC 管理,Go 會安排分配與建立該值的詞法作用域相關的記憶體。一般來說,這比依賴 GC 更高效,因為 Go 編譯器能夠預先確定何時可以釋放該記憶體併發出清理的機器指令。通常,我們將以這種方式為 Go 值分配記憶體稱為“棧分配”,因為空間儲存在 goroutine 棧上。

由於 Go 編譯器無法確定其生命週期而無法以這種方式分配記憶體的 Go 值,被稱為逃逸到堆。可以將“堆”視為記憶體分配的包羅永珍之地,用於儲存那些需要被放置在某個地方的 Go 值。在堆上分配記憶體的行為通常被稱為“動態記憶體分配”,因為編譯器和執行時對於這塊記憶體如何使用以及何時可以清理幾乎不做任何假設。這就是 GC 的作用所在:它是一個專門識別和清理動態記憶體分配的系統。

Go 值需要逃逸到堆的原因有很多。其中一個原因是其大小是動態確定的。例如,考慮一個 slice 的底層陣列,其初始大小由變數而不是常量決定。請注意,逃逸到堆也必須是傳遞的:如果對一個 Go 值的引用被寫入到另一個已被確定會逃逸的 Go 值中,那麼該值也必須逃逸。

Go 值是否逃逸取決於它使用的上下文以及 Go 編譯器的逃逸分析演算法。試圖精確列舉值何時逃逸將是脆弱且困難的:該演算法本身相當複雜,並且在 Go 版本之間會有變化。有關如何識別哪些值逃逸以及哪些值不逃逸的更多詳細資訊,請參閱關於消除堆分配的部分。

追蹤式垃圾回收

垃圾回收可能指許多不同的自動回收記憶體的方法;例如,引用計數。在本文件的上下文中,垃圾回收指的是追蹤式垃圾回收,它透過傳遞地跟蹤指標來識別正在使用(即所謂的活躍)的物件。

讓我們更嚴格地定義這些術語。

物件和指向其他物件的指標共同構成了物件圖。為了識別活躍記憶體,GC 從程式的開始遍歷物件圖,這些根是指向程式中肯定正在使用的物件的指標。根的兩個例子是區域性變數和全域性變數。遍歷物件圖的過程稱為掃描。您可能在 Go 文件中看到的另一個短語是物件是否可達,這僅僅意味著該物件可以透過掃描過程被發現。另請注意,除了一個例外,一旦記憶體變得不可達,它就保持不可達狀態。

這個基本演算法是所有追蹤式 GC 的共同之處。追蹤式 GC 的區別在於它們發現記憶體是活躍的之後做什麼。Go 的 GC 使用標記-清除技術,這意味著為了跟蹤其進度,GC 還會將遇到的值標記為活躍。追蹤完成後,GC 會遍歷堆中的所有記憶體,並將標記的所有記憶體設定為可供分配。這個過程稱為清除

您可能熟悉另一種技術,即實際將物件移動到記憶體的新位置,並在原位置留下一個轉發指標,稍後用於更新應用程式的所有指標。我們將以這種方式移動物件的 GC 稱為移動式 GC;Go 是一種非移動式 GC。

GC 週期

由於 Go GC 是標記-清除 GC,它大體上分為兩個階段:標記階段和清除階段。儘管這個說法可能看起來是同義反復,但它包含一個重要的見解:在所有記憶體都被追蹤之前,不可能釋放記憶體以供重新分配,因為可能仍然存在一個未掃描的指標使某個物件保持活躍。因此,清除操作必須完全與標記操作分開。此外,當沒有 GC 相關工作可做時,GC 可能根本不活躍。GC 會不斷地在清除、不活躍和標記這三個階段中迴圈,這被稱為GC 週期。為了本文件的目的,我們將 GC 週期視為從清除開始,然後是不活躍,再是標記。

接下來的幾節將重點介紹如何建立對 GC 成本的直觀理解,以幫助使用者根據自己的需求調整 GC 引數。

理解成本

GC 本質上是一個複雜的軟體,構建在更復雜的系統之上。在試圖理解 GC 並調整其行為時,很容易陷入細節之中。本節旨在提供一個框架,用於推理 Go GC 的成本及其調優引數。

首先,考慮基於三個簡單公理的 GC 成本模型。

  1. GC 只涉及兩種資源:物理記憶體和 CPU 時間。

  2. GC 的記憶體成本包括活躍堆記憶體、標記階段之前分配的新堆記憶體以及元資料空間,即使元資料空間與前述成本成比例,但相對較小。

    GC 週期 N 的記憶體成本 = 週期 N-1 的活躍堆 + 新堆

    活躍堆記憶體是上一個 GC 週期確定為活躍的記憶體,而新堆記憶體是當前週期分配的任何記憶體,這些記憶體在週期結束時可能活躍也可能不活躍。程式在任何給定時間點有多少活躍記憶體是程式的屬性,而不是 GC 可以直接控制的。

  3. GC 的 CPU 成本被建模為每個週期的固定成本,以及與活躍堆大小成比例的邊際成本。

    GC 週期 N 的 CPU 時間 = 每個週期的固定 CPU 時間成本 + 每位元組的平均 CPU 時間成本 * 週期 N 中發現的活躍堆記憶體

    每個週期的固定 CPU 時間成本包括每個週期固定次數發生的事情,例如初始化下一個 GC 週期的內部結構。這個成本通常很小,僅為完整性而包含。

    GC 的大部分 CPU 成本是標記和掃描,這由邊際成本體現。標記和掃描的平均成本取決於 GC 實現,但也取決於程式的行為。例如,更多指標意味著更多的 GC 工作,因為 GC 至少需要訪問程式中的所有指標。連結串列和樹等結構也更難讓 GC 並行遍歷,從而增加了每位元組的平均成本。

    這個模型忽略了清除成本,清除成本與總堆記憶體成比例,包括已死亡的記憶體(必須使其可供分配)。對於 Go 當前的 GC 實現,清除比標記和掃描快得多,因此成本相比較而言可以忽略不計。

這個模型簡單但有效:它準確地對 GC 的主要成本進行了分類。它還告訴我們,垃圾回收器的總 CPU 成本取決於給定時間範圍內的 GC 週期總數。最後,這個模型中蘊含著 GC 基本的時間/空間權衡。

要了解原因,讓我們探討一個受限但有用的場景:穩態。從 GC 的角度來看,應用程式的穩態由以下屬性定義:

讓我們來看一個例子。假設某個應用程式分配速度為 10 MiB/s,GC 的掃描速度為 100 MiB/cpu-秒(這是虛構的),並且固定 GC 成本為零。穩態對活躍堆的大小沒有假設,但為了簡單起見,假設這個應用程式的活躍堆始終是 10 MiB。(注意:恆定的活躍堆並不意味著所有新分配的記憶體都是死亡的。這意味著,在 GC 執行後,舊堆和新堆記憶體的某種組合是活躍的。)如果每個 GC 週期恰好每 1 cpu-秒發生一次,那麼我們的示例應用程式在穩態下,每個 GC 週期將有 20 MiB 的總堆大小。並且在每個 GC 週期中,GC 將需要 0.1 cpu-秒來完成其工作,導致 10% 的開銷。

現在假設每個 GC 週期發生頻率較低,每 2 cpu-秒一次。那麼,我們的示例應用程式在穩態下,每個 GC 週期將有 30 MiB 的總堆大小。但每次 GC 週期,GC 仍然只需要 0.1 cpu-秒來完成其工作。所以這意味著我們的 GC 開銷剛剛從 10% 下降到 5%,代價是記憶體使用量增加了 50%。

這種開銷的變化就是前面提到的基本時間/空間權衡。而GC 頻率是這種權衡的核心:如果我們更頻繁地執行 GC,那麼使用的記憶體就會更少,反之亦然。但是 GC 實際執行的頻率是多久呢?在 Go 中,決定 GC 何時開始是使用者可以控制的主要引數。

GOGC

從高層次來看,GOGC 決定了 GC CPU 和記憶體之間的權衡。

它的工作原理是確定每個 GC 週期後的目標堆大小,即下一個週期中總堆大小的目標值。GC 的目標是在總堆大小超過目標堆大小之前完成一次收集週期。總堆大小定義為前一個週期結束時的活躍堆大小,加上自前一個週期以來應用程式分配的任何新堆記憶體。同時,目標堆記憶體定義為:

目標堆記憶體 = 活躍堆 + (活躍堆 + GC 根) * GOGC / 100

舉個例子,考慮一個 Go 程式,其活躍堆大小為 8 MiB,goroutine 棧佔用 1 MiB,全域性變數中的指標佔用 1 MiB。那麼,當 GOGC 值為 100 時,在下一次 GC 執行之前將分配的新記憶體量將是 10 MiB,或者說 10 MiB 工作量的 100%,總堆佔用為 18 MiB。當 GOGC 值為 50 時,將分配 50%,即 5 MiB。當 GOGC 值為 200 時,將分配 200%,即 20 MiB。

注意:GOGC 從 Go 1.18 開始才將根集包含在內。在此之前,它只計算活躍堆。通常,goroutine 棧中的記憶體量很小,並且活躍堆大小主導所有其他 GC 工作來源,但在程式有數十萬個 goroutine 的情況下,GC 的判斷會變差。

堆目標控制著 GC 頻率:目標越大,GC 可以等待更長時間才開始另一個標記階段,反之亦然。雖然精確的公式對於進行估算很有用,但最好從其基本目的來考慮 GOGC:一個在 GC CPU 和記憶體權衡中選擇一個點的引數。關鍵點是,GOGC 加倍會使堆記憶體開銷加倍,並將 GC CPU 成本大致減半,反之亦然。(要了解完整解釋,請參閱附錄。)

注意:目標堆大小隻是一個目標,GC 週期可能不會正好在該目標處結束,原因有很多。一方面,足夠大的堆分配可以直接超過目標。然而,GC 實現中還存在其他超出本指南目前使用的GC 模型的原因。有關更多詳細資訊,請參閱延遲部分,完整細節可在附加資源中找到。

GOGC 可以透過 GOGC 環境變數(所有 Go 程式都識別)或透過 runtime/debug 包中的 SetGCPercent API 進行配置。

注意,還可以透過設定 GOGC=off 或呼叫 SetGCPercent(-1) 來完全關閉 GC(前提是記憶體限制不適用)。概念上,此設定等同於將 GOGC 設定為無窮大,因為在觸發 GC 之前的新記憶體量是無限制的。

為了更好地理解我們到目前為止討論的所有內容,請嘗試下面的互動式視覺化,它基於前面討論的GC 成本模型構建。該視覺化描繪了某個程式的執行過程,其非 GC 工作需要 10 秒的 CPU 時間來完成。在第一秒,它執行一些初始化步驟(增加其活躍堆),然後進入穩態。應用程式總共分配了 200 MiB,同時有 20 MiB 是活躍的。它假設唯一需要完成的相關 GC 工作來自活躍堆,並且(不切實際地)應用程式沒有使用額外的記憶體。

使用滑塊調整 GOGC 的值,以檢視應用程式在總持續時間和 GC 開銷方面的響應。每個 GC 週期結束時,新堆都會降至零。新堆降至零所需的時間是週期 N 的標記階段和週期 N+1 的清除階段的總時間。請注意,此視覺化(以及本指南中的所有視覺化)假設應用程式在 GC 執行時暫停,因此 GC CPU 成本完全由新堆記憶體降至零所需的時間表示。這只是為了簡化視覺化;相同的直觀理解仍然適用。X 軸移動以始終顯示程式的完整 CPU 時間持續時間。請注意,GC 使用的額外 CPU 時間會增加總持續時間。

GOGC

請注意,GC 總是會產生一定的 CPU 和峰值記憶體開銷。隨著 GOGC 增加,CPU 開銷降低,但峰值記憶體與活躍堆大小成比例增加。隨著 GOGC 降低,峰值記憶體需求減少,但會增加額外的 CPU 開銷。

注意:圖表中顯示的是 CPU 時間,而不是完成程式的實際時鐘時間。如果程式執行在 1 個 CPU 上並充分利用其資源,那麼兩者是等效的。真實的程式可能執行在多核系統上,並且不會始終 100% 利用 CPU。在這種情況下,GC 對實際時鐘時間的影響會更小。

注意:Go GC 的最小總堆大小為 4 MiB,因此如果 GOGC 設定的目標低於此值,它會被向上取整。視覺化反映了這一細節。

這是另一個更具動態性和真實性的示例。同樣,應用程式在沒有 GC 的情況下需要 10 個 CPU 秒來完成,但在中途,穩態分配速率顯著增加,並且活躍堆大小在第一階段略有變化。這個示例演示了當活躍堆大小實際變化時穩態可能是什麼樣子,以及更高的分配速率如何導致更頻繁的 GC 週期。

GOGC

記憶體限制

在 Go 1.19 之前,GOGC 是唯一可以用來修改 GC 行為的引數。雖然它作為設定權衡的方法效果很好,但它沒有考慮到可用記憶體是有限的。考慮當活躍堆大小出現瞬時峰值時會發生什麼:由於 GC 將選擇與該活躍堆大小成比例的總堆大小,因此必須針對峰值活躍堆大小配置 GOGC,即使在通常情況下更高的 GOGC 值可以提供更好的權衡。

下面的視覺化演示了這種瞬時堆峰值情況。

GOGC

如果示例工作負載在一個可用記憶體略高於 60 MiB 的容器中執行,那麼 GOGC 就不能增加到超過 100,即使在其他 GC 週期中,有可用的記憶體來利用額外的記憶體。此外,在某些應用程式中,這些瞬時峰值可能很少見且難以預測,從而導致偶爾、不可避免且可能代價高昂的記憶體不足情況。

這就是為什麼在 1.19 版本中,Go 添加了對設定執行時記憶體限制的支援。記憶體限制可以透過所有 Go 程式都識別的 GOMEMLIMIT 環境變數或透過 runtime/debug 包中可用的 SetMemoryLimit 函式進行配置。

此記憶體限制設定了 Go 執行時可以使用的總記憶體量的上限。包含的特定記憶體集根據 runtime.MemStats 定義為表示式:

Sys - HeapReleased

或者等效地用 runtime/metrics 包的術語表示:

/memory/classes/total:bytes - /memory/classes/heap/released:bytes

由於 Go GC 對其使用的堆記憶體量有明確的控制權,因此它根據此記憶體限制以及 Go 執行時使用的其他記憶體量來設定總堆大小。

下面的視覺化圖表展示了與 GOGC 部分相同的單階段穩態工作負載,但這次增加了來自 Go 執行時額外的 10 MiB 開銷,並且具有可調節的記憶體限制。嘗試同時調整 GOGC 和記憶體限制,看看會發生什麼。

GOGC
記憶體限制

請注意,當記憶體限制降低到低於 GOGC 決定的峰值記憶體(GOGC 為 100 時為 42 MiB)時,GC 會更頻繁地執行以將峰值記憶體保持在限制範圍內。

回到我們之前瞬時堆峰值的例子,透過設定記憶體限制並提高 GOGC,我們可以獲得兩全其美的結果:不會突破記憶體限制,並且資源利用更經濟。請嘗試下面的互動式視覺化。

GOGC
記憶體限制

請注意,對於某些 GOGC 和記憶體限制的值,峰值記憶體使用量會停留在記憶體限制設定的值,但程式其餘部分的執行仍然遵守 GOGC 設定的總堆大小規則。

這一觀察引出了另一個有趣的細節:即使將 GOGC 設定為關閉,記憶體限制仍然受到遵守!實際上,這種特定配置代表了資源利用的最大化,因為它設定了維持某個記憶體限制所需的最低 GC 頻率。在這種情況下,程式執行的所有過程中,堆大小都會增加以達到記憶體限制。

現在,雖然記憶體限制顯然是一個強大的工具,但使用記憶體限制並非沒有代價,並且肯定不會使 GOGC 的用處失效。

考慮一下當活躍堆增長到足以使總記憶體使用量接近記憶體限制時會發生什麼。在上面的穩態視覺化中,嘗試關閉 GOGC,然後慢慢地進一步降低記憶體限制,看看會發生什麼。請注意,隨著 GC 不斷執行以維持一個不可能的記憶體限制,應用程式的總時間將開始無限增長。

這種由於持續的 GC 週期導致程式無法取得合理進展的情況稱為顛簸。它尤其危險,因為它會有效地導致程式停滯。更糟糕的是,它可能恰好發生在我們試圖用 GOGC 避免的相同情況下:足夠大的瞬時堆峰值可能導致程式無限期停滯!在瞬時堆峰值視覺化中嘗試降低記憶體限制(大約 30 MiB 或更低),注意最壞的行為是如何從堆峰值開始的。

在許多情況下,無限期停滯比記憶體不足情況更糟糕,記憶體不足往往會導致更快的失敗。

因此,記憶體限制被定義為軟限制。Go 執行時不保證在所有情況下都能維持此記憶體限制;它只承諾付出一些合理的努力。放寬記憶體限制對於避免顛簸行為至關重要,因為它為 GC 提供了一種退出方式:允許記憶體使用量超過限制,以避免在 GC 中花費過多時間。

其內部工作原理是,GC 在某個時間視窗內設定了它可以使用的 CPU 時間上限(對於非常短暫的 CPU 使用瞬時峰值有一定的滯後)。此限制當前設定為大致 50%,時間視窗為 2 * GOMAXPROCS CPU 秒。限制 GC CPU 時間的後果是 GC 的工作被延遲,同時 Go 程式可能會繼續分配新的堆記憶體,甚至超過記憶體限制。

50% GC CPU 限制背後的直覺是基於在可用記憶體充足的情況下對程式的最壞影響。如果記憶體限制配置錯誤,被錯誤地設定得太低,程式最多隻會減慢 2 倍,因為 GC 不能佔用超過 50% 的 CPU 時間。

注意:本頁面上的視覺化不模擬 GC CPU 限制。

建議使用場景

雖然記憶體限制是一個強大的工具,並且 Go 執行時採取措施減輕因誤用而產生的最壞行為,但仍需審慎使用。以下是一些關於記憶體限制何時最有用、何時適用以及何時可能弊大於利的建議。

延遲

本文件中的視覺化模擬了應用程式在 GC 執行期間暫停的情況。確實存在這種行為的 GC 實現,它們被稱為“全暫停”(stop-the-world)GC。

然而,Go GC 並非完全全暫停(stop-the-world),它的大部分工作是與應用程式併發進行的。這主要是為了降低應用程式的延遲。具體來說,就是單個計算單元(例如網頁請求)的端到端持續時間。到目前為止,本文件主要考慮了應用程式的吞吐量(例如每秒處理的網頁請求)。請注意,GC 週期部分中的每個示例都側重於程式執行的總 CPU 持續時間。然而,對於像 Web 服務這樣的應用,這樣的持續時間意義要小得多。雖然吞吐量對於 Web 服務仍然重要(即每秒查詢次數),但通常每個單獨請求的延遲更加重要。

在延遲方面,全暫停(stop-the-world)GC 可能需要相當長的時間來執行其標記和清掃階段,在此期間,應用程式,在 Web 服務的上下文中,任何正在處理的請求都無法取得進一步進展。Go GC 則避免使任何全域性應用程式暫停的時間長度與堆大小成比例,並且其核心追蹤演算法是在應用程式活躍執行時進行的。(演算法上,暫停時間與 GOMAXPROCS 更強的比例關係,但最常見的是由停止正在執行的 goroutine 所需的時間決定。)併發回收並非沒有成本:實踐中,它通常會導致吞吐量低於等效的全暫停垃圾回收器。然而,重要的是要注意,較低的延遲並不固有地意味著較低的吞吐量,並且 Go 垃圾回收器的效能隨著時間的推移在延遲和吞吐量方面都穩步提高。

Go 當前 GC 的併發特性並沒有使本文件迄今為止討論的任何內容失效:沒有任何陳述依賴於這種設計選擇。GC 頻率仍然是 GC 在 CPU 時間和記憶體之間為吞吐量進行權衡的主要方式,實際上,它在延遲方面也扮演著這個角色。這是因為 GC 的大部分成本發生在標記階段活躍時。

那麼,關鍵的啟示是,降低 GC 頻率也可能帶來延遲的改善。這不僅適用於透過修改調優引數(如增加 GOGC 和/或記憶體限制)來降低 GC 頻率,也適用於最佳化指南中描述的最佳化。

然而,延遲通常比吞吐量更難理解,因為它是程式瞬時執行的產物,而不僅僅是成本的彙總。因此,延遲與 GC 頻率之間的聯絡不那麼直接。下面列出了對於那些傾向於深入研究的人來說,可能導致延遲的來源。

  1. GC 在標記和清掃階段之間轉換時短暫的全暫停(stop-the-world),
  2. 排程延遲,因為 GC 在標記階段佔用 25% 的 CPU 資源,
  3. 使用者 goroutine 響應高分配率而協助 GC,
  4. GC 在標記階段時,指標寫入需要額外的工作,以及
  5. 正在執行的 goroutine 必須暫停以便掃描它們的根。

這些延遲來源在執行跟蹤中可見,除了指標寫入需要額外工作。

終結器(Finalizers)、清理器(Cleanups)和弱指標(Weak Pointers)

垃圾回收提供了一種只用有限記憶體實現無限記憶體的假象。記憶體被分配但從未顯式釋放,這使得與原始的手動記憶體管理相比,API 更簡單,併發演算法更容易。(一些具有手動管理記憶體的語言使用“智慧指標”和編譯時所有權跟蹤等替代方法來確保物件被釋放,但這些特性深入嵌入到這些語言的 API 設計約定中。)

只有存活的物件——那些可以從全域性變數或某個 goroutine 中的計算訪問到的物件——才能影響程式的行為。物件變得不可達(“死亡”)後的任何時間,它都可以被 GC 安全地回收。這允許各種 GC 設計,例如 Go 當前使用的追蹤(tracing)設計。物件死亡在語言層面不是一個可觀察到的事件。

然而,Go 的執行時庫提供了三個打破這種假象的特性:清理器(cleanups)弱指標(weak pointers)終結器(finalizers)。這些特性都提供了某種方式來觀察和響應物件死亡,對於終結器,甚至可以逆轉它。這當然會使 Go 程式複雜化,並給 GC 實現增加額外的負擔。儘管如此,這些特性之所以存在是因為它們在各種情況下都很有用,並且 Go 程式一直在使用它們並從中受益。

有關每個特性的詳細資訊,請參閱其包文件(runtime.AddCleanupweak.Pointerruntime.SetFinalizer)。下面是使用這些特性的一些一般性建議,每個特性可能遇到的常見問題概述,以及測試這些特性的使用方法的建議。

一般建議

常見的清理器問題

常見的弱指標問題

常見的終結器問題

測試物件死亡

使用這些特性時,為使用它們的程式碼編寫測試有時會很棘手。以下是一些關於為使用這些特性的程式碼編寫健壯測試的技巧。

其他資源

雖然上面提供的資訊是準確的,但它缺乏足夠的細節來完全理解 Go GC 設計中的成本和權衡。欲瞭解更多資訊,請參閱以下其他資源。

關於虛擬記憶體的說明

本指南主要關注 GC 的物理記憶體使用,但一個經常出現的問題是這具體意味著什麼,以及它與虛擬記憶體(通常在 top 等程式中顯示為“VSS”)相比如何。

物理記憶體是大多數計算機中實際物理 RAM 晶片中的記憶體。虛擬記憶體是作業系統提供的物理記憶體抽象,用於隔離程式。程式保留不對映到任何物理地址的虛擬地址空間通常也是可以接受的。

由於虛擬記憶體只是作業系統維護的一種對映,因此進行不對映到物理記憶體的大型虛擬記憶體保留通常非常廉價。

Go 執行時通常在幾個方面依賴於這種虛擬記憶體成本的觀點

因此,像 top 中的“VSS”這樣的虛擬記憶體指標通常在理解 Go 程式的記憶體佔用方面作用不大。相反,應關注“RSS”及類似的測量指標,它們更直接地反映了物理記憶體使用情況。

最佳化指南

識別成本

在嘗試最佳化 Go 應用程式與 GC 的互動方式之前,首先重要的是確定 GC 是否是主要的成本來源。

Go 生態系統提供了許多用於識別成本和最佳化 Go 應用程式的工具。要快速瞭解這些工具,請參閱診斷指南。在這裡,我們將重點關注這些工具的一個子集以及應用它們的合理順序,以便理解 GC 的影響和行為。

  1. CPU 效能分析(CPU profiles)

    一個好的起點是CPU 效能分析。CPU 效能分析提供了 CPU 時間花費在哪裡的概述,但對於未經訓練的人來說,可能很難識別 GC 在特定應用程式中扮演的角色有多大。幸運的是,瞭解 GC 的作用主要歸結為知道 `runtime` 包中不同函式的意思。下面是用於解釋 CPU 效能分析報告的一些有用函式。

    請注意,下面列出的函式不是葉子函式,因此它們可能不會出現在 pprof 工具使用 top 命令提供的預設檢視中。相反,請使用 top -cum 命令或直接對這些函式使用 list 命令,並關注累積百分比列。

    • runtime.gcBgMarkWorker:後臺標記工作 goroutine 的入口點。此處花費的時間與 GC 頻率以及物件圖的複雜性和大小成比例。它代表了應用程式在標記和掃描上花費的時間基線。

      請注意,在這些 goroutine 內部,你會看到對 runtime.gcDrainMarkWorkerDedicatedruntime.gcDrainMarkWorkerFractionalruntime.gcDrainMarkWorkerIdle 的呼叫,它們指示了工作 goroutine 的型別。在一個大部分時間處於空閒狀態的 Go 應用程式中,Go GC 會利用額外的(空閒)CPU 資源來更快地完成其工作,這由 runtime.gcDrainMarkWorkerIdle 符號指示。因此,此處的時間可能佔 CPU 取樣的大部分,Go GC 認為這些 CPU 資源是空閒的。如果應用程式變得更活躍,空閒工作 goroutine 的 CPU 時間將下降。發生這種情況的一個常見原因是應用程式完全在一個 goroutine 中執行,但 GOMAXPROCS > 1。

    • runtime.mallocgc:堆記憶體分配器的入口點。此處花費大量累積時間(>15%)通常表示正在分配大量記憶體。

    • runtime.gcAssistAlloc:goroutine 進入此函式以讓出部分時間來協助 GC 進行掃描和標記。此處花費大量累積時間(>5%)表明應用程式的分配速度可能超過了 GC 的處理速度。這表示 GC 的影響程度特別高,也代表了應用程式花費在標記和掃描上的時間。注意,這包含在 runtime.mallocgc 的呼叫樹中,因此也會增加該函式的累積時間。

  2. 執行跟蹤(Execution traces)

    雖然 CPU 效能分析非常適合識別總共花費的時間,但對於更微妙、罕見或與延遲相關的效能成本則不太有用。另一方面,執行跟蹤提供了一個 Go 程式在短暫執行視窗內的豐富而深入的檢視。它們包含與 Go GC 相關的各種事件,並且可以直接觀察特定的執行路徑,以及應用程式如何與 Go GC 互動。所有跟蹤的 GC 事件在跟蹤檢視器中都方便地標記了出來。

    有關如何開始使用執行跟蹤,請參閱runtime/trace 包的文件

  3. GC 跟蹤(GC traces)

    當其他方法都無效時,Go GC 提供了一些不同的特定跟蹤,它們提供了對 GC 行為更深入的洞察。這些跟蹤總是直接列印到 STDERR,每個 GC 週期一行,並且透過所有 Go 程式都能識別的 GODEBUG 環境變數進行配置。由於它們需要對 GC 實現的細節有一定的熟悉,因此主要用於除錯 Go GC 本身,但偶爾也可能有助於更好地理解 GC 行為。

    透過設定 GODEBUG=gctrace=1 可以啟用核心 GC 跟蹤。該跟蹤產生的輸出在runtime 包文件中的環境變數部分有說明。

    一個補充的 GC 跟蹤稱為“步調跟蹤”(pacer trace),它提供了更深入的洞察,透過設定 GODEBUG=gcpacertrace=1 啟用。解釋此輸出需要理解 GC 的“步調控制器”(pacer)(參閱其他資源),這超出了本指南的範圍。

消除堆分配

減少 GC 成本的一種方法是讓 GC 從一開始就管理更少的值。下面描述的技術可以帶來一些最大的效能改進,因為正如GOGC 部分所演示的那樣,Go 程式的分配率是 GC 頻率的主要因素,而 GC 頻率是本指南使用的關鍵成本指標。

堆效能分析(Heap profiling)

確定 GC 是重要的成本來源之後,消除堆分配的下一步是找出它們主要來自哪裡。為此,記憶體效能分析(實際上是堆記憶體效能分析)非常有用。有關如何開始使用它們,請查閱文件

記憶體效能分析報告描述了堆分配來自程式中的哪些位置,透過分配時的堆疊跟蹤來識別它們。每個記憶體效能分析報告可以從四個方面細分記憶體。

可以使用 pprof 工具的 -sample_index 標誌或在互動式使用工具時透過 sample_index 選項在這些不同的堆記憶體檢視之間切換。

注意:記憶體效能分析報告預設只採樣堆物件的一個子集,因此它們不包含關於每一個堆分配的資訊。然而,這足以找到熱點。要更改取樣率,請參閱runtime.MemProfileRate

為了降低 GC 成本,alloc_space 通常是最有用的檢視,因為它直接對應於分配率。此檢視將指出能夠帶來最大收益的分配熱點。

逃逸分析(Escape analysis)

一旦藉助堆效能分析確定了候選的堆分配位置,如何才能消除它們呢?關鍵是利用 Go 編譯器的逃逸分析,讓 Go 編譯器為這部分記憶體找到替代的、更高效的儲存位置,例如在 goroutine 棧中。幸運的是,Go 編譯器能夠描述為什麼它決定將 Go 值逃逸到堆上。有了這些知識,就變成了重組你的原始碼來改變分析結果的問題(這通常是最難的部分,但超出了本指南的範圍)。

至於如何訪問 Go 編譯器的逃逸分析資訊,最簡單的方法是透過 Go 編譯器支援的一個除錯標誌,該標誌以文字格式描述了它對某個包應用或未應用的所有最佳化。這包括值是否逃逸。請嘗試以下命令,其中 [package] 是某個 Go 包路徑。

$ go build -gcflags=-m=3 [package]

此資訊也可以在支援 LSP 的編輯器中以疊加層(overlay)的形式視覺化;它作為程式碼操作暴露出來。例如,在 VS Code 中,呼叫“Source Action... > Show compiler optimization details”命令以啟用當前包的診斷資訊。(你也可以執行“Go: Toggle compiler optimization details”命令。)使用此配置設定來控制顯示哪些註釋

  1. 透過ui.diagnostic.annotations 設定為包含 escape 來啟用逃逸分析的疊加層。

最後,Go 編譯器以機器可讀的 (JSON) 格式提供此資訊,可用於構建額外的自定義工具。有關更多資訊,請參閱Go 原始碼中的文件

實現特定的最佳化

Go GC 對存活記憶體的構成很敏感,因為複雜的物件和指標圖既限制了並行性,又為 GC 帶來了更多工作。因此,GC 針對特定的常見結構包含了一些最佳化。對效能最佳化最直接有用的最佳化如下所示。

注意:應用下面的最佳化可能會透過模糊意圖來降低程式碼的可讀性,並且可能無法在不同的 Go 版本中持續有效。請優先僅在最重要的地方應用這些最佳化。可以使用識別成本部分中列出的工具來識別這些位置。

此外,GC 必須與它看到的幾乎每個指標互動,因此使用 slice 的索引(例如)而不是指標,有助於降低 GC 成本。

Linux 透明大頁(THP)

當程式訪問記憶體時,CPU 需要將其使用的虛擬記憶體地址轉換為引用它試圖訪問的資料的物理記憶體地址。為此,CPU 會查詢“頁表”,這是一個由作業系統管理的、表示虛擬記憶體到物理記憶體對映的資料結構。頁表中的每個條目代表一個不可分割的物理記憶體塊,稱為頁面,因此得名。

透明大頁(THP)是 Linux 的一個特性,它透明地將支援連續虛擬記憶體區域的物理記憶體頁面替換為更大的記憶體塊,稱為大頁(huge pages)。透過使用更大的塊,表示相同記憶體區域所需的頁表條目更少,從而提高了頁表查詢時間。然而,如果系統只使用了大頁的一小部分,更大的塊意味著更多的浪費。

在生產環境中執行 Go 程式時,在 Linux 上啟用透明大頁(THP)可以提高吞吐量和延遲,代價是增加記憶體使用。堆較小的應用程式往往不會從 THP 中受益,並可能最終使用大量額外記憶體(高達 50%)。然而,堆較大的應用程式(1 GiB 或更大)傾向於獲得相當大的收益(高達 10% 的吞吐量),而額外記憶體開銷不大(1-2% 或更少)。無論哪種情況,瞭解你的 THP 設定都有幫助,並且始終建議進行實驗。

可以透過修改 /sys/kernel/mm/transparent_hugepage/enabled 在 Linux 環境中啟用或停用透明大頁。有關更多詳細資訊,請參閱官方 Linux 管理員指南。如果選擇在 Linux 生產環境中啟用透明大頁,我們建議為 Go 程式設定以下額外選項。

附錄

關於 GOGC 的補充說明

GOGC 部分聲稱,將 GOGC 加倍會使堆記憶體開銷加倍,並將 GC CPU 成本減半。為了理解原因,我們用數學方法來分解一下。

首先,堆目標(heap target)設定了總堆大小的目標。然而,這個目標主要影響新的堆記憶體,因為存活堆(live heap)是應用程式的基礎。

目標堆記憶體 = 活躍堆 + (活躍堆 + GC 根) * GOGC / 100

總堆記憶體 = 存活堆 + 新堆記憶體

新堆記憶體 = (存活堆 + GC 根) * GOGC / 100

由此我們可以看到,將 GOGC 加倍也會使應用程式在每個週期分配的新堆記憶體量加倍,這反映了堆記憶體開銷。請注意,存活堆 + GC 根 是 GC 需要掃描的記憶體量的近似值。

接下來,我們來看看 GC CPU 成本。總成本可以分解為每個週期的成本乘以在某個時間段 T 內的 GC 頻率。

總 GC CPU 成本 = (每個週期的 GC CPU 成本) * (GC 頻率) * T

每個週期的 GC CPU 成本可以從GC 模型推匯出來

每個週期的 GC CPU 成本 = (存活堆 + GC 根) * (每位元組成本) + 固定成本

請注意,此處忽略了清掃階段的成本,因為標記和掃描的成本占主導地位。

穩態由恆定的分配速率和恆定的每位元組成本定義,因此在穩態下,我們可以從這個新的堆記憶體推匯出 GC 頻率

GC 頻率 = (分配速率) / (新堆記憶體) = (分配速率) / ((存活堆 + GC 根) * GOGC / 100)

將這些組合起來,我們得到總成本的完整方程

總 GC CPU 成本 = (分配速率) / ((存活堆 + GC 根) * GOGC / 100) * ((存活堆 + GC 根) * (每位元組成本) + 固定成本) * T

對於足夠大的堆(這代表了大多數情況),GC 週期的邊際成本占主導地位,高於固定成本。這使得總 GC CPU 成本公式可以得到顯著簡化。

總 GC CPU 成本 = (分配速率) / (GOGC / 100) * (每位元組成本) * T

從這個簡化公式可以看出,如果我們將 GOGC 加倍,總 GC CPU 成本將減半。(請注意,本指南中的視覺化模擬了固定成本,因此它們報告的 GC CPU 開銷在 GOGC 加倍時不會正好減半。)此外,GC CPU 成本主要由分配速率和掃描記憶體的每位元組成本決定。有關如何具體降低這些成本的更多資訊,請參閱最佳化指南

注意:存活堆的大小與 GC 實際需要掃描的記憶體量之間存在差異:相同大小但結構不同的存活堆將導致不同的 CPU 成本,但記憶體成本相同,從而產生不同的權衡。這就是為什麼堆的結構是穩態定義的一部分。可以說,堆目標應該只包含可掃描的存活堆,作為 GC 需要掃描記憶體的更接近的近似值,但這會在可掃描存活堆非常少但總存活堆很大的情況下導致退化行為。