Go 垃圾收集器指南

簡介

本指南旨在透過提供深入了解 Go 垃圾回收機制,協助進階 Go 使用者更深入了解其應用程式成本。它也提供指導,說明 Go 使用者如何利用這些深入了解,改善其應用程式的資源使用率。它不假設任何垃圾回收知識,但假設熟悉 Go 程式語言。

Go 語言負責安排 Go 值的儲存;在大部分情況下,Go 開發人員不需要關心這些值儲存在哪裡,或為什麼儲存在那裡(如果有的話)。然而,在實務上,這些值通常需要儲存在電腦實體記憶體中,而實體記憶體是一種有限的資源。由於它是有限的,因此必須小心管理記憶體並加以回收,以避免在執行 Go 程式時用盡記憶體。Go 實作的工作就是視需要配置和回收記憶體。

自動回收記憶體的另一個術語是垃圾回收。在高層面上,垃圾收集器(簡稱 GC)是一個系統,它會代表應用程式回收記憶體,方法是找出哪些記憶體區塊不再需要。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 值需要逃逸到堆疊的原因有很多。其中一個原因可能是其大小是由動態決定的。例如,考慮一個切片的備份陣列,其初始大小是由變數而非常數決定的。請注意,逃逸到堆疊也必須是遞移的:如果對 Go 值的參考寫入到另一個已被判定為逃逸的 Go 值,則該值也必須逃逸。

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

追蹤垃圾回收

垃圾回收可能會指許多自動回收記憶體的不同方法;例如,參考計數。在本文件中,垃圾回收是指追蹤垃圾回收,它透過遞移方式追蹤指標來識別正在使用的、所謂的存活物件。

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

物件與指向其他物件的指標共同組成物件圖形。為了識別活的記憶體,GC 從程式根部開始遍歷物件圖形,根部是指標,用來識別程式中明確正在使用的物件。根部的兩個範例是局部變數和全域變數。遍歷物件圖形的程序稱為掃描

此基本演算法是所有追蹤 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 週期判定為存活的記憶體,而新堆記憶體是當前週期分配的任何記憶體,這些記憶體在結束時可能存活或不存活。

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

    注意:漸近而言,清除的規模比標記和掃描差,因為它必須執行與整個堆大小成比例的工作,包括判定為非存活(即「死亡」)的記憶體。然而,在目前的實作中,清除比標記和掃描快很多,因此在這個討論中可以忽略其相關成本。

這個模型簡單但有效:它準確地分類了 GC 的主要成本。然而,這個模型並沒有說明這些成本的規模,也沒有說明它們如何互動。為了建模,考慮以下情況,從這裡開始稱為穩態

注意:穩態可能看似人為的,但它代表應用程式在某個恆定工作負載下的行為。當然,工作負載甚至可以在應用程式執行時改變,但通常應用程式行為看起來像是一堆穩態串在一起,中間穿插一些暫時行為。

注意:穩態沒有對執行中堆疊做出任何假設。它可能隨著每個後續 GC 週期而增加,也可能縮小,或保持不變。但是,嘗試在後續說明中涵蓋所有這些情況既乏味又沒有說明力,因此本指南將重點放在執行中堆疊保持不變的範例。 GOGC 區段 會更詳細地探討非恆定執行中堆疊場景。

在穩態中,當動態堆大小為常數時,只要 GC 在經過相同時間後執行,每個 GC 週期在成本模型中看起來都將相同。這是因為在該固定時間內,在應用程式分配的固定速率下,將配置固定數量的新的堆記憶體。因此,當動態堆大小為常數,且新的堆記憶體常數時,記憶體使用量將始終相同。而且,由於動態堆大小相同,邊際 GC CPU 成本將相同,且固定成本將在某個常規間隔內發生。

現在考慮如果 GC 將其執行點轉移到較晚的時間。然後,將配置更多記憶體,但每個 GC 週期仍將產生相同的 CPU 成本。然而,在某些其他固定時間視窗中,完成的 GC 週期較少,導致整體 CPU 成本降低。如果 GC 決定較早開始,則相反的情況將成立:配置的記憶體較少,且 CPU 成本會更頻繁地發生。

此情況代表 GC 可以進行的 CPU 時間和記憶體之間的基本權衡,由 GC 實際執行的頻率控制。換句話說,權衡完全由GC 頻率定義。

還有一個細節有待定義,那就是 GC 應該決定何時開始。請注意,這會直接設定任何特定穩態中的 GC 頻率,定義權衡。在 Go 中,決定 GC 應何時開始是使用者可以控制的主要參數。

GOGC

在高層級,GOGC 決定 GC CPU 和記憶體之間的權衡。

它的運作方式是確定每個 GC 週期後的目標堆大小,這是下一個週期中總堆大小的目標值。GC 的目標是在總堆大小超過目標堆大小之前完成收集週期。總堆大小定義為前一個週期結束時的動態堆大小,加上自前一個週期以來應用程式配置的任何新的堆記憶體。同時,目標堆記憶體定義為

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

例如,考慮一個執行緒堆疊大小為 1 MiB,全域變數中指標大小為 1 MiB,執行緒堆疊大小為 8 MiB 的 Go 程式。那麼,當 GOGC 值為 100 時,在下一次 GC 執行前將配置的新記憶體量將為 10 MiB,也就是 10 MiB 工作量的 100%,總堆疊空間為 18 MiB。當 GOGC 值為 50 時,將為 50%,也就是 5 MiB。當 GOGC 值為 200 時,將為 200%,也就是 20 MiB。

注意:GOGC 僅在 Go 1.18 中包含根集。以前,它只會計算執行緒堆疊。通常,執行緒堆疊中的記憶體量非常小,而執行緒堆疊大小會佔用所有其他 GC 工作來源,但在程式有數十萬個執行緒的情況下,GC 會做出錯誤的判斷。

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

注意:目標堆疊大小只是一個目標,GC 週期可能無法在該目標上完成的原因有很多。首先,足夠大的堆疊配置可能會超過目標。然而,其他原因出現在 GC 實作中,這超出了本指南迄今為止使用的GC 模型。如需更多詳細資訊,請參閱延遲區段,但可以在其他資源中找到完整詳細資訊。

GOGC 可以透過GOGC環境變數(所有 Go 程式都識別)或透過runtime/debug套件中的SetGCPercent API 來設定。

請注意,GOGC 也可用於完全關閉 GC(前提是不套用記憶體限制),方法是設定 GOGC=off 或呼叫 SetGCPercent(-1)。從概念上來說,此設定等於將 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,則即使其他 GC 週期有可用記憶體可使用額外記憶體,GOGC 也無法增加超過 100。此外,在某些應用程式中,這些暫態峰值可能很罕見且難以預測,導致偶發、不可避免且可能代價高昂的記憶體不足狀況。

這就是為什麼在 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 實作,它們稱為「停止世界」GC。

但是,Go GC 並非完全停止世界,且執行大部分工作時與應用程式同時進行。這主要是為了減少應用程式的延遲。具體來說,單一運算單位的端到端持續時間(例如網路要求)。到目前為止,此文件主要考量應用程式的通量(例如每秒處理的網路要求)。請注意,GC 週期 區段中的每個範例都專注於執行程式的總 CPU 持續時間。但是,對於網路服務來說,這種持續時間的意義遠低。雖然通量對於網路服務仍然很重要(即每秒查詢),但通常每個個別要求的延遲更重要。

在延遲方面,停止世界的 GC 可能需要相當長的時間來執行標記和清除階段,在此期間,應用程式,以及在網路服務的上下文中,任何正在進行的請求都無法進一步進行。相反地,Go GC 避免讓任何全域應用程式暫停的時間與堆積大小成正比,而且核心追蹤演算法是在應用程式積極執行時執行的。(暫停與 GOMAXPROCS 演算法成正比,但最常見的是由停止執行 goroutine 所需的時間所決定。)同時收集並非沒有代價:在實務上,它通常會導致設計的處理量低於等效的停止世界垃圾收集器。不過,重要的是要注意,較低的延遲並不一定意味著較低的處理量,而且 Go 垃圾收集器的效能隨著時間的推移在延遲和處理量方面都有穩定的提升。

Go 的目前 GC 的並行性質並不會使本文檔中迄今討論的任何內容失效:沒有任何陳述依賴於此設計選擇。GC 頻率仍然是 GC 在 CPU 時間和記憶體之間進行處理量權衡的主要方式,而且事實上,它也承擔了延遲的角色。這是因為 GC 的大部分成本是在標記階段進行時產生的。

因此,關鍵的重點是,降低 GC 頻率也可能導致延遲改善。這不僅適用於透過修改調整參數(例如增加 GOGC 和/或記憶體限制)而降低 GC 頻率,也適用於最佳化指南中所述的最佳化。

不過,延遲通常比處理量更難理解,因為它是程式逐刻執行的結果,而不仅仅是成本的彙總。因此,延遲與 GC 頻率之間的關聯性較不直接。以下是延遲可能的來源清單,供有興趣深入探討的人參考。

  1. 當 GC 在標記和掃描階段之間轉換時,會暫停簡短的停止世界。
  2. 當 GC 處於標記階段時,會佔用 25% 的 CPU 資源,因此會造成排程延遲。
  3. 使用者 goroutine 在回應高配置率時協助 GC。
  4. 當 GC 處於標記階段時,指標寫入需要額外的處理。
  5. 必須暫停正在執行的 goroutine,才能掃描其根。

這些延遲來源在 執行追蹤 中可見,但指標寫入需要額外的處理則除外。

其他資源

雖然上面提供的資訊是準確的,但它缺乏詳細資訊,無法完全了解 Go GC 設計中的成本和權衡。如需更多資訊,請參閱以下其他資源。

關於虛擬記憶體的注意事項

本指南主要關注 GC 的實體記憶體使用,但經常出現的問題是這實際上是什麼意思,以及它與虛擬記憶體(通常在 top 等程式中顯示為「VSS」)有何不同。

實體記憶體是大多數電腦中實際實體 RAM 晶片中的記憶體。虛擬記憶體是作業系統提供的實體記憶體抽象,用於將程式彼此隔離。程式保留未對應到任何實體位址的虛擬位址空間通常也是可以接受的。

由於虛擬記憶體只是作業系統維護的對應,因此建立未對應到實體記憶體的大型虛擬記憶體保留通常非常便宜。

Go 執行時期通常依賴於虛擬記憶體成本的這種觀點,有以下幾種方式

因此,top 中的「VSS」等虛擬記憶體指標通常對於瞭解 Go 程式的記憶體空間沒有什麼幫助。相反地,請專注於「RSS」和類似的測量,這些測量更直接地反映實體記憶體使用。

最佳化指南

識別成本

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

Go 生態系統提供許多工具來識別成本和最佳化 Go 應用程式。有關這些工具的簡要概述,請參閱 診斷指南。在此,我們將重點放在這些工具的子集和合理的順序,以了解 GC 的影響和行為。

  1. CPU 剖析

    一個好的開始是 CPU 剖析。CPU 剖析提供了 CPU 時間花費在哪裡的概觀,儘管對於未受過訓練的人來說,可能很難識別 GC 在特定應用程式中所扮演的角色的大小。幸運的是,了解 GC 如何融入主要是歸結為知道 `runtime` 套件中不同函式的含義。以下是解釋 CPU 剖析的有用函式子集。

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

    • runtime.gcBgMarkWorker:背景標記工作常式的進入點。在此花費的時間會隨著 GC 頻率以及物件圖形的複雜性和大小而縮放。它代表應用程式花費在標記和掃描上的時間基準。

      注意:在這些 goroutine 中,您會找到呼叫runtime.gcDrainMarkWorkerDedicatedruntime.gcDrainMarkWorkerFractionalruntime.gcDrainMarkWorkerIdle,這些呼叫會指出工作執行緒類型。在一個主要處於閒置狀態的 Go 應用程式中,Go GC 會使用額外的(閒置)CPU 資源,以更快完成其工作,這會以runtime.gcDrainMarkWorkerIdle符號表示。因此,這裡的時間可能代表 CPU 範例中的一大部分,而 Go GC 認為這些時間是空閒的。如果應用程式變得更活躍,閒置工作執行緒中的 CPU 時間將會下降。發生這種情況的一個常見原因是,如果一個應用程式完全在一個 goroutine 中執行,但GOMAXPROCS大於 1。

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

    • runtime.gcAssistAlloc:函式 goroutine 進入此函式,以提供一些時間協助 GC 進行掃描和標記。在此處花費大量累積時間(>5%)表示應用程式在配置速度方面可能超過 GC。它表示 GC 影響特別大,也代表應用程式花費在標記和掃描的時間。請注意,這包含在runtime.mallocgc呼叫樹中,因此也會增加該呼叫樹。

  2. 執行緒追蹤

    雖然 CPU 剖析對於找出時間花費在何處很有用,但對於指出更細微、罕見或特別與延遲相關的效能成本,它們就沒那麼有用。另一方面,執行追蹤提供深入且詳盡的視角,可以檢視 Go 程式執行的一小段時間。它們包含與 Go GC 相關的各種事件,並且可以直接觀察到特定的執行路徑,以及應用程式如何與 Go GC 互動。追蹤檢視器中會將追蹤的所有 GC 事件標示為 GC 事件。

    請參閱 runtime/trace 套件的文件,了解如何開始執行追蹤。

  3. GC 追蹤

    如果其他方法都失敗了,Go GC 會提供一些不同的特定追蹤,提供更深入的 GC 行為見解。這些追蹤會始終直接列印到 STDERR,每個 GC 週期一行,並透過所有 Go 程式都能辨識的 GODEBUG 環境變數進行設定。它們主要用於除錯 Go GC 本身,因為它們需要具備 GC 實作的特定知識,但偶爾也可以用來更深入了解 GC 行為。

    透過設定 GODEBUG=gctrace=1 可以啟用核心 GC 追蹤。此追蹤產生的輸出記載在 runtime 套件文件中的環境變數區段 中。

    稱為「配速器追蹤」的補充 GC 追蹤提供更深入的見解,並透過設定 GODEBUG=gcpacertrace=1 啟用。詮釋此輸出需要了解 GC 的「配速器」(請參閱 其他資源),這不在本指南的範圍內。

消除堆疊配置

減少 GC 成本的一種方法是讓 GC 從一開始就管理較少的值。以下描述的技術可以產生一些最大的效能改善,因為正如 GOGC 區段 所示,Go 程式的配置率是 GC 頻率的主要因素,而 GC 頻率是本指南中使用的主要成本指標。

堆配置分析

確認 GC 是顯著成本來源 之後,消除堆配置的下一步是找出它們大多數來自何處。為此,記憶體配置分析(實際上是堆記憶體配置分析)非常有用。查看 文件 以了解如何開始使用它們。

記憶體配置分析描述程式中堆配置的來源,並透過配置點的堆疊追蹤來識別它們。每個記憶體配置分析可以用四種方式細分記憶體。

可以在這些不同的堆記憶體檢視之間切換,方法是對 pprof 工具使用 -sample_index 旗標,或在互動使用該工具時透過 sample_index 選項。

注意:預設情況下,記憶體配置分析僅取樣堆物件的子集,因此它們不會包含每個堆配置的資訊。但是,這足以找出熱點。若要變更取樣率,請參閱 runtime.MemProfileRate

為了降低 GC 成本,alloc_space 通常是最有用的檢視,因為它直接對應於配置率。此檢視將指出配置熱點,這些熱點將提供最大的好處。

逸出分析

一旦候選堆疊配置地點已在 堆疊設定檔 的協助下識別出來,要如何消除它們?關鍵在於利用 Go 編譯器的逃逸分析,讓 Go 編譯器為這個記憶體尋找替代且更有效率的儲存空間,例如在 goroutine 堆疊中。幸運的是,Go 編譯器有能力描述它決定將 Go 值逃逸到堆疊的原因。有了這項知識,這就變成重新組織您的原始程式碼,以變更分析結果的問題(這通常是最困難的部分,但不在本指南的範圍內)。

至於如何存取 Go 編譯器逃逸分析的資訊,最簡單的方法是透過 Go 編譯器支援的偵錯旗標,它會以文字格式描述它套用或未套用至某些套件的所有最佳化。這包括值是否逃逸。試試以下指令,其中 [package] 是某些 Go 套件路徑。

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

此資訊也可以在 VS Code 中視覺化為疊加層。此疊加層會在 VS Code Go 外掛程式設定中設定和啟用。

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

最後,Go 編譯器會以機器可讀(JSON)格式提供此資訊,可供建置其他自訂工具使用。如需更多相關資訊,請參閱 原始 Go 程式碼中的文件

實作特定的最佳化

Go GC 對即時記憶體的人口統計資料很敏感,因為物件和指標的複雜圖形會限制平行處理,並為 GC 產生更多工作。因此,GC 包含一些針對特定常見結構的最佳化。以下是對效能最佳化最直接有用的部分。

注意:套用下列最佳化可能會因為模糊意圖而降低程式碼的可讀性,而且可能無法在 Go 版本中維持。建議僅在最重要的部分套用這些最佳化。此類部分可透過使用 識別成本 一節中所列的工具來識別。

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

Linux 透明巨型頁面 (THP)

當程式存取記憶體時,CPU 需要將它使用的 虛擬記憶體 位址轉換為實體記憶體位址,以參照它嘗試存取的資料。為此,CPU 會參照「分頁表」,一種由作業系統管理的資料結構,用於表示從虛擬記憶體到實體記憶體的對應。分頁表中的每個項目都代表一個不可分割的實體記憶體區塊,稱為頁面,因此得名。

透明巨大頁面 (THP) 是一種 Linux 功能,它會透明地將連續虛擬記憶體區域的實體記憶體頁面替換為稱為巨大頁面的較大記憶體區塊。透過使用較大的區塊,可以減少表示相同記憶體區域所需的頁面表項目,進而改善頁面表查詢時間。不過,較大的區塊表示如果系統只使用巨大頁面的一小部分,就會產生更多浪費。

在執行階段執行 Go 程式時,在 Linux 上啟用透明巨大頁面可以改善吞吐量和延遲,但代價是使用更多記憶體。堆積較小的應用程式往往無法從 THP 中受益,而且可能會使用大量額外記憶體 (高達 50%)。不過,堆積較大的應用程式 (1 GiB 或更大) 往往會受益良多 (吞吐量提升達 10%),而且不會產生太多額外記憶體開銷 (1-2% 或更低)。不論哪種情況,了解 THP 設定都會有所幫助,而且始終建議進行實驗。

可以在 Linux 環境中修改 /sys/kernel/mm/transparent_hugepage/enabled 來啟用或停用透明巨大頁面。請參閱 官方 Linux 管理指南 以取得更多詳細資料。如果您選擇讓 Linux 生產環境啟用透明巨大頁面,我們建議為 Go 程式套用下列額外設定。

附錄

關於 GOGC 的其他注意事項

GOGC 區段 宣稱將 GOGC 加倍會將堆疊記憶體開銷加倍,並將 GC CPU 成本減半。為了了解原因,讓我們以數學方式分解它。

首先,堆疊目標設定總堆疊大小的目標。不過,此目標主要影響新的堆疊記憶體,因為動態堆疊對於應用程式至關重要。

目標堆記憶體 = 動態堆 + (動態堆 + 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 總成本減半。(請注意,本指南中的視覺化確實模擬了固定成本,因此當 GOGC 加倍時,它們報告的 GC CPU 負擔不會完全減半。)此外,GC CPU 成本在很大程度上取決於配置速率和掃描記憶體的每個位元組的成本。有關如何具體降低這些成本的更多資訊,請參閱 最佳化指南

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