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 版本之間會有變化。有關如何識別哪些值逃逸以及哪些值不逃逸的更多詳細資訊,請參閱關於消除堆分配的部分。
追蹤式垃圾回收
垃圾回收可能指許多不同的自動回收記憶體的方法;例如,引用計數。在本文件的上下文中,垃圾回收指的是追蹤式垃圾回收,它透過傳遞地跟蹤指標來識別正在使用(即所謂的活躍)的物件。
讓我們更嚴格地定義這些術語。
-
物件——物件是一塊動態分配的記憶體,包含一個或多個 Go 值。
-
指標——引用物件內任何值的記憶體地址。這自然包括
*T
形式的 Go 值,但也包括內建 Go 值的一部分。字串、切片、通道、對映和介面值都包含 GC 必須追蹤的記憶體地址。
物件和指向其他物件的指標共同構成了物件圖。為了識別活躍記憶體,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 成本模型。
-
GC 只涉及兩種資源:物理記憶體和 CPU 時間。
-
GC 的記憶體成本包括活躍堆記憶體、標記階段之前分配的新堆記憶體以及元資料空間,即使元資料空間與前述成本成比例,但相對較小。
GC 週期 N 的記憶體成本 = 週期 N-1 的活躍堆 + 新堆
活躍堆記憶體是上一個 GC 週期確定為活躍的記憶體,而新堆記憶體是當前週期分配的任何記憶體,這些記憶體在週期結束時可能活躍也可能不活躍。程式在任何給定時間點有多少活躍記憶體是程式的屬性,而不是 GC 可以直接控制的。
-
GC 的 CPU 成本被建模為每個週期的固定成本,以及與活躍堆大小成比例的邊際成本。
GC 週期 N 的 CPU 時間 = 每個週期的固定 CPU 時間成本 + 每位元組的平均 CPU 時間成本 * 週期 N 中發現的活躍堆記憶體
每個週期的固定 CPU 時間成本包括每個週期固定次數發生的事情,例如初始化下一個 GC 週期的內部結構。這個成本通常很小,僅為完整性而包含。
GC 的大部分 CPU 成本是標記和掃描,這由邊際成本體現。標記和掃描的平均成本取決於 GC 實現,但也取決於程式的行為。例如,更多指標意味著更多的 GC 工作,因為 GC 至少需要訪問程式中的所有指標。連結串列和樹等結構也更難讓 GC 並行遍歷,從而增加了每位元組的平均成本。
這個模型忽略了清除成本,清除成本與總堆記憶體成比例,包括已死亡的記憶體(必須使其可供分配)。對於 Go 當前的 GC 實現,清除比標記和掃描快得多,因此成本相比較而言可以忽略不計。
這個模型簡單但有效:它準確地對 GC 的主要成本進行了分類。它還告訴我們,垃圾回收器的總 CPU 成本取決於給定時間範圍內的 GC 週期總數。最後,這個模型中蘊含著 GC 基本的時間/空間權衡。
要了解原因,讓我們探討一個受限但有用的場景:穩態。從 GC 的角度來看,應用程式的穩態由以下屬性定義:
-
應用程式分配新記憶體的速度(位元組/秒)是恆定的。
這意味著,從 GC 的角度來看,應用程式的工作負載隨時間推移大致相同。例如,對於 Web 服務,這將是一個恆定的請求率,平均處理相同型別的請求,並且每個請求的平均生命週期保持大致恆定。
-
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 時間會增加總持續時間。
請注意,GC 總是會產生一定的 CPU 和峰值記憶體開銷。隨著 GOGC 增加,CPU 開銷降低,但峰值記憶體與活躍堆大小成比例增加。隨著 GOGC 降低,峰值記憶體需求減少,但會增加額外的 CPU 開銷。
注意:圖表中顯示的是 CPU 時間,而不是完成程式的實際時鐘時間。如果程式執行在 1 個 CPU 上並充分利用其資源,那麼兩者是等效的。真實的程式可能執行在多核系統上,並且不會始終 100% 利用 CPU。在這種情況下,GC 對實際時鐘時間的影響會更小。
注意:Go GC 的最小總堆大小為 4 MiB,因此如果 GOGC 設定的目標低於此值,它會被向上取整。視覺化反映了這一細節。
這是另一個更具動態性和真實性的示例。同樣,應用程式在沒有 GC 的情況下需要 10 個 CPU 秒來完成,但在中途,穩態分配速率顯著增加,並且活躍堆大小在第一階段略有變化。這個示例演示了當活躍堆大小實際變化時穩態可能是什麼樣子,以及更高的分配速率如何導致更頻繁的 GC 週期。
記憶體限制
在 Go 1.19 之前,GOGC 是唯一可以用來修改 GC 行為的引數。雖然它作為設定權衡的方法效果很好,但它沒有考慮到可用記憶體是有限的。考慮當活躍堆大小出現瞬時峰值時會發生什麼:由於 GC 將選擇與該活躍堆大小成比例的總堆大小,因此必須針對峰值活躍堆大小配置 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 為 100 時為 42 MiB)時,GC 會更頻繁地執行以將峰值記憶體保持在限制範圍內。
回到我們之前瞬時堆峰值的例子,透過設定記憶體限制並提高 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 執行時採取措施減輕因誤用而產生的最壞行為,但仍需審慎使用。以下是一些關於記憶體限制何時最有用、何時適用以及何時可能弊大於利的建議。
-
利用記憶體限制:當您的 Go 程式的執行環境完全在您的控制之下,並且該 Go 程式是唯一能夠訪問某些資源集(即某種記憶體預留,例如容器記憶體限制)的程式時。
一個很好的例子是將 Web 服務部署到具有固定可用記憶體量的容器中。
在這種情況下,一個好的經驗法則是預留額外的 5-10% 的餘量,以考慮 Go 執行時不瞭解的記憶體來源。
-
可以隨時調整記憶體限制:以適應不斷變化的情況。
一個很好的例子是 cgo 程式,其中 C 庫可能暫時需要使用大量記憶體。
-
不要在設定記憶體限制的同時將 GOGC 設定為 off:如果 Go 程式可能與其他程式共享其有限的記憶體,並且這些程式通常與 Go 程式解耦。相反,保留記憶體限制,因為它可能有助於抑制不希望的瞬時行為,但將 GOGC 設定為對於平均情況來說較小且合理的值。
儘管可能想嘗試為共同租戶程式“保留”記憶體,除非程式完全同步(例如 Go 程式呼叫某個子程序並在被呼叫的程式執行時阻塞),否則結果將不可靠,因為兩個程式都不可避免地需要更多記憶體。讓 Go 程式在不需要時使用更少的記憶體將帶來更可靠的整體結果。此建議也適用於過度承諾情況,即在同一臺機器上執行的容器的記憶體限制總和可能超過機器實際可用的物理記憶體。
-
不要使用記憶體限制:當部署到您無法控制的執行環境時,特別是當您的程式記憶體使用量與其輸入成比例時。
一個很好的例子是 CLI 工具或桌面應用程式。當不清楚可能輸入什麼型別的資料,或者系統上有多少可用記憶體時,將記憶體限制內建到程式中可能會導致令人困惑的崩潰和糟糕的效能。此外,高階終端使用者如果願意,總是可以自行設定記憶體限制。
-
不要設定記憶體限制:當程式已經接近其環境的記憶體限制時,試圖透過設定記憶體限制來避免記憶體不足情況。
這有效地將記憶體不足的風險替換為嚴重的應用程式效能下降的風險,這通常不是一個有利的權衡,即使 Go 努力減輕抖動(thrashing)。在這種情況下,更有效的方法是增加環境的記憶體限制(然後可能設定記憶體限制)或降低 GOGC(這比抖動緩解提供了一個更清晰的權衡)。
延遲
本文件中的視覺化模擬了應用程式在 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 頻率之間的聯絡不那麼直接。下面列出了對於那些傾向於深入研究的人來說,可能導致延遲的來源。
- GC 在標記和清掃階段之間轉換時短暫的全暫停(stop-the-world),
- 排程延遲,因為 GC 在標記階段佔用 25% 的 CPU 資源,
- 使用者 goroutine 響應高分配率而協助 GC,
- GC 在標記階段時,指標寫入需要額外的工作,以及
- 正在執行的 goroutine 必須暫停以便掃描它們的根。
這些延遲來源在執行跟蹤中可見,除了指標寫入需要額外工作。
終結器(Finalizers)、清理器(Cleanups)和弱指標(Weak Pointers)
垃圾回收提供了一種只用有限記憶體實現無限記憶體的假象。記憶體被分配但從未顯式釋放,這使得與原始的手動記憶體管理相比,API 更簡單,併發演算法更容易。(一些具有手動管理記憶體的語言使用“智慧指標”和編譯時所有權跟蹤等替代方法來確保物件被釋放,但這些特性深入嵌入到這些語言的 API 設計約定中。)
只有存活的物件——那些可以從全域性變數或某個 goroutine 中的計算訪問到的物件——才能影響程式的行為。物件變得不可達(“死亡”)後的任何時間,它都可以被 GC 安全地回收。這允許各種 GC 設計,例如 Go 當前使用的追蹤(tracing)設計。物件死亡在語言層面不是一個可觀察到的事件。
然而,Go 的執行時庫提供了三個打破這種假象的特性:清理器(cleanups)、弱指標(weak pointers)和終結器(finalizers)。這些特性都提供了某種方式來觀察和響應物件死亡,對於終結器,甚至可以逆轉它。這當然會使 Go 程式複雜化,並給 GC 實現增加額外的負擔。儘管如此,這些特性之所以存在是因為它們在各種情況下都很有用,並且 Go 程式一直在使用它們並從中受益。
有關每個特性的詳細資訊,請參閱其包文件(runtime.AddCleanup、weak.Pointer、runtime.SetFinalizer)。下面是使用這些特性的一些一般性建議,每個特性可能遇到的常見問題概述,以及測試這些特性的使用方法的建議。
一般建議
-
編寫單元測試。
清理器、弱指標和終結器的確切時機很難預測,即使經過多次連續執行,也很容易讓自己相信一切正常。但也很容易犯下微妙的錯誤。為它們編寫測試可能很棘手,但考慮到它們的使用非常微妙,測試比通常情況下更重要。
-
避免在典型的 Go 程式碼中直接使用這些特性。
這些是低階特性,具有微妙的限制和行為。例如,不能保證清理器或終結器會在程式退出時執行,甚至根本不會執行。它們 API 文件中的長篇註釋應被視為警告。絕大多數 Go 程式碼並不會直接使用這些特性中受益,只能間接受益。
-
將這些機制的使用封裝在包內。
在可能的情況下,不要讓這些機制的使用洩露到你的包的公共 API 中;提供讓使用者難以或不可能誤用它們的介面。例如,與其要求使用者在分配的 C 記憶體上設定清理來釋放它,不如編寫一個封裝包並將該細節隱藏在內部。
-
將帶有終結器、清理器和弱指標的物件的訪問許可權限制在建立並應用它們的包內。
這與前一點有關,但值得明確指出,因為它是以一種不易出錯的方式使用這些特性的非常強大的模式。例如,unique 包在底層使用弱指標,但完全封裝了弱指標指向的物件。這些值永遠不能被應用程式的其他部分修改,只能透過 Value 方法複製,為包使用者保留了無限記憶體的假象。
-
在可能的情況下,優先確定性地清理非記憶體資源,將終結器和清理器作為後備。
清理器和終結器非常適合記憶體資源,例如外部分配的記憶體(如來自 C)或對
mmap
對映的引用。透過 C 的 malloc 分配的記憶體最終必須透過 C 的 free 釋放。將呼叫free
的終結器附加到 C 記憶體的包裝物件上,是確保 C 記憶體最終因垃圾回收而被回收的合理方法。然而,非記憶體資源(如檔案描述符)往往受到 Go 執行時通常不瞭解的系統限制。此外,給定 Go 程式中垃圾回收器的時機通常是包作者幾乎無法控制的(例如,GC 執行頻率由 GOGC 控制,操作人員在實踐中可以將其設定為各種不同的值)。這兩個事實共同使得清理器和終結器不適合用作釋放非記憶體資源的唯一機制。
如果你是一位包作者,暴露了一個封裝非記憶體資源的 API,請考慮提供一個顯式 API 來確定性地釋放資源(透過
Close
方法或類似方法),而不是僅僅依賴垃圾回收器透過清理器或終結器。相反,更傾向於將清理器和終結器用作程式設計師錯誤的盡力而為的處理器,可以透過像 os.File 那樣無論如何都清理資源,或者將確定性清理失敗的錯誤報告給使用者。 -
優先使用清理器而不是終結器。
從歷史上看,新增終結器是為了簡化 Go 程式碼和 C 程式碼之間的介面以及清理非記憶體資源。預期的用途是將它們應用於擁有 C 記憶體或其他非記憶體資源的包裝物件,以便在 Go 程式碼使用完該資源後可以將其釋放。這些原因至少部分解釋了為什麼終結器範圍狹窄,為什麼任何給定物件只能有一個終結器,以及為什麼該終結器只能附加到物件的第一個位元組。這個限制已經限制了一些用例。例如,任何希望內部快取關於傳遞給它的物件資訊的包,在該物件消失後無法清理該資訊。
但更糟的是,終結器效率低下且容易出錯,因為它們會復活它們所附加的物件,以便可以將其傳遞給終結器函式(甚至可能在那之後繼續存活)。這個簡單的事實意味著,如果物件是引用週期的一部分,它將永遠無法被釋放,並且支援該物件的記憶體至少要等到下一個垃圾回收週期才能被重用。
然而,由於終結器會復活物件,它們的執行順序比清理器有更好的定義。因此,終結器對於清理具有複雜銷燬順序要求的結構仍然可能(但很少)有用。
但在 Go 1.24 及更高版本中的所有其他用途中,我們建議使用清理器,因為它們比終結器更靈活、更不易出錯且更高效。
常見的清理器問題
-
附加了清理器的物件不能從清理函式中可達(例如,透過捕獲的區域性變數)。這將阻止物件被回收,並阻止清理器執行。
f := new(myFile) f.fd = syscall.Open(...) runtime.AddCleanup(f, func(fd int) { syscall.Close(f.fd) // Mistake: We reference f, so this cleanup won't run! }, f.fd)
附加了清理器的物件不能從清理函式的引數中可達。這將阻止物件被回收,並阻止清理器執行。
f := new(myFile) f.fd = syscall.Open(...) runtime.AddCleanup(f, func(f *myFile) { syscall.Close(f.fd) }, f) // Mistake: We reference f, so this cleanup wouldn't ever run. This specific case also panics.
終結器有明確的執行順序,但清理器沒有。清理器也可以相互併發執行。
runtime.GC
不會等待不可達物件的清理器執行完畢,只等待它們全部排隊。
常見的弱指標問題
-
弱指標可能在意外的時間從其
Value
方法開始返回nil
。始終使用nil
檢查來保護對Value
的呼叫,並準備後備方案。 -
當弱指標用作 map 鍵時,它們不影響 map 值的可達性。因此,如果弱指標 map 鍵指向一個也可以從 map 值訪問到的物件,該物件仍然被視為可達的。
常見的終結器問題
-
附加了終結器的物件不能透過任何路徑從自身可達(換句話說,它們不能處於引用週期中)。這將阻止物件被回收,並阻止終結器執行。
f := new(myCycle) f.self = f // Mistake: f is reachable from f, so this finalizer would never run. runtime.SetFinalizer(f, func(f *myCycle) { ... })
附加了終結器的物件不能從終結器函式中可達(例如,透過捕獲的區域性變數)。這將阻止物件被回收,並阻止終結器執行。
f := new(myFile) f.fd = syscall.Open(...) runtime.SetFinalizer(f, func(_ *myFile) { syscall.Close(f.fd) // Mistake: We reference the outer f, so this cleanup won't run! })
帶終結器的物件引用鏈(例如,在連結串列中)至少需要與鏈中物件數量一樣多的 GC 週期才能全部清理。保持終結器鏈淺!
// Mistake: reclaiming this linked list will take at least 10 GC cycles. node := new(linkedListNode) for range 10 { tmp := new(linkedListNode) tmp.next = node node = tmp runtime.SetFinalizer(node, func(node *linkedListNode) { ... }) }
避免在包邊界返回的物件上放置終結器。這使得你的包的使用者可以呼叫 runtime.SetFinalizer
來修改你返回的物件上的終結器,這可能是一種意想不到的行為,並且你的包的使用者最終可能會依賴它。
runtime.GC
不會等待不可達物件的終結器執行完畢,只等待它們全部排隊。
測試物件死亡
使用這些特性時,為使用它們的程式碼編寫測試有時會很棘手。以下是一些關於為使用這些特性的程式碼編寫健壯測試的技巧。
- 避免將此類測試與其他測試並行執行。這對於儘可能提高確定性並隨時掌握系統狀態有很大幫助。
- 在進入測試時,使用
runtime.GC
來建立基線。使用runtime.GC
強制弱指標變為nil
,並排隊清理器和終結器以便執行。 -
runtime.GC
不會等待清理器和終結器執行,它只會將它們排隊。為了編寫儘可能健壯的測試,可以在測試中注入一種方式來阻塞等待清理器或終結器(例如,從測試中向清理器和/或終結器傳遞一個可選的 channel,並在執行完成後向 channel 寫入)。如果這太困難或不可能,另一種方法是迴圈檢查某個清理後的狀態是否為真。例如,
os
測試在一個迴圈中呼叫runtime.Gosched
,當檔案變得不可達後,檢查它是否已被關閉。 -
如果編寫使用終結器的測試,並且你有一個使用終結器的物件鏈,你將需要至少呼叫
runtime.GC
次數等於測試能建立的最深鏈的長度,以確保所有終結器都執行。 -
在競爭模式下測試,以發現併發清理器之間,以及清理器和終結器程式碼與程式碼庫其餘部分之間的競爭條件。
其他資源
雖然上面提供的資訊是準確的,但它缺乏足夠的細節來完全理解 Go GC 設計中的成本和權衡。欲瞭解更多資訊,請參閱以下其他資源。
- The GC Handbook——關於垃圾回收器設計的優秀通用資源和參考資料。
- TCMalloc——C/C++ 記憶體分配器 TCMalloc 的設計文件,Go 記憶體分配器以此為基礎。
- Go 1.5 GC 公告——宣佈 Go 1.5 併發 GC 的部落格文章,其中更詳細地描述了該演算法。
- Getting to Go——關於 Go GC 設計演變直到 2018 年的深入介紹。
- Go 1.5 併發 GC 步調控制——用於確定何時啟動併發標記階段的設計文件。
- 更智慧的清掃——修訂 Go 執行時將記憶體返回給作業系統的設計文件。
- 可伸縮的頁面分配器——修訂 Go 執行時管理從作業系統獲取記憶體的方式的設計文件。
- GC 步調控制器重新設計 (Go 1.18)——修訂確定何時啟動併發標記階段的演算法的設計文件。
- 軟記憶體限制 (Go 1.19)——軟記憶體限制的設計文件。
關於虛擬記憶體的說明
本指南主要關注 GC 的物理記憶體使用,但一個經常出現的問題是這具體意味著什麼,以及它與虛擬記憶體(通常在 top
等程式中顯示為“VSS”)相比如何。
物理記憶體是大多數計算機中實際物理 RAM 晶片中的記憶體。虛擬記憶體是作業系統提供的物理記憶體抽象,用於隔離程式。程式保留不對映到任何物理地址的虛擬地址空間通常也是可以接受的。
由於虛擬記憶體只是作業系統維護的一種對映,因此進行不對映到物理記憶體的大型虛擬記憶體保留通常非常廉價。
Go 執行時通常在幾個方面依賴於這種虛擬記憶體成本的觀點
-
Go 執行時從不刪除它對映的虛擬記憶體。相反,它使用大多數作業系統提供的特殊操作來顯式釋放與某個虛擬記憶體範圍相關的任何物理記憶體資源。
該技術被顯式用於管理記憶體限制,並將 Go 執行時不再需要的記憶體返回給作業系統。Go 執行時還在後臺持續釋放它不再需要的記憶體。有關更多資訊,請參閱其他資源。
-
在 32 位平臺上,Go 執行時會預留 128 MiB 到 512 MiB 的地址空間用於堆,以限制碎片問題。
-
Go 執行時在實現幾個內部資料結構時使用了大型虛擬記憶體地址空間保留。在 64 位平臺上,這些通常至少需要約 700 MiB 的虛擬記憶體佔用。在 32 位平臺上,它們的佔用微不足道。
因此,像 top
中的“VSS”這樣的虛擬記憶體指標通常在理解 Go 程式的記憶體佔用方面作用不大。相反,應關注“RSS”及類似的測量指標,它們更直接地反映了物理記憶體使用情況。
最佳化指南
識別成本
在嘗試最佳化 Go 應用程式與 GC 的互動方式之前,首先重要的是確定 GC 是否是主要的成本來源。
Go 生態系統提供了許多用於識別成本和最佳化 Go 應用程式的工具。要快速瞭解這些工具,請參閱診斷指南。在這裡,我們將重點關注這些工具的一個子集以及應用它們的合理順序,以便理解 GC 的影響和行為。-
CPU 效能分析(CPU profiles)
一個好的起點是CPU 效能分析。CPU 效能分析提供了 CPU 時間花費在哪裡的概述,但對於未經訓練的人來說,可能很難識別 GC 在特定應用程式中扮演的角色有多大。幸運的是,瞭解 GC 的作用主要歸結為知道 `runtime` 包中不同函式的意思。下面是用於解釋 CPU 效能分析報告的一些有用函式。
請注意,下面列出的函式不是葉子函式,因此它們可能不會出現在
pprof
工具使用top
命令提供的預設檢視中。相反,請使用top -cum
命令或直接對這些函式使用list
命令,並關注累積百分比列。 -
runtime.gcBgMarkWorker
:後臺標記工作 goroutine 的入口點。此處花費的時間與 GC 頻率以及物件圖的複雜性和大小成比例。它代表了應用程式在標記和掃描上花費的時間基線。請注意,在這些 goroutine 內部,你會看到對
runtime.gcDrainMarkWorkerDedicated
、runtime.gcDrainMarkWorkerFractional
和runtime.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
的呼叫樹中,因此也會增加該函式的累積時間。 -
執行跟蹤(Execution traces)
雖然 CPU 效能分析非常適合識別總共花費的時間,但對於更微妙、罕見或與延遲相關的效能成本則不太有用。另一方面,執行跟蹤提供了一個 Go 程式在短暫執行視窗內的豐富而深入的檢視。它們包含與 Go GC 相關的各種事件,並且可以直接觀察特定的執行路徑,以及應用程式如何與 Go GC 互動。所有跟蹤的 GC 事件在跟蹤檢視器中都方便地標記了出來。
有關如何開始使用執行跟蹤,請參閱
runtime/trace
包的文件。 -
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 是重要的成本來源之後,消除堆分配的下一步是找出它們主要來自哪裡。為此,記憶體效能分析(實際上是堆記憶體效能分析)非常有用。有關如何開始使用它們,請查閱文件。
記憶體效能分析報告描述了堆分配來自程式中的哪些位置,透過分配時的堆疊跟蹤來識別它們。每個記憶體效能分析報告可以從四個方面細分記憶體。
inuse_objects
——細分存活的物件數量。inuse_space
——按存活物件使用的記憶體位元組數細分。alloc_objects
——細分自 Go 程式開始執行以來已分配的物件數量。alloc_space
——細分自 Go 程式開始執行以來分配的總記憶體量。
可以使用 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”命令。)使用此配置設定來控制顯示哪些註釋
- 透過將
ui.diagnostic.annotations
設定為包含escape
來啟用逃逸分析的疊加層。
最後,Go 編譯器以機器可讀的 (JSON) 格式提供此資訊,可用於構建額外的自定義工具。有關更多資訊,請參閱Go 原始碼中的文件。
實現特定的最佳化
Go GC 對存活記憶體的構成很敏感,因為複雜的物件和指標圖既限制了並行性,又為 GC 帶來了更多工作。因此,GC 針對特定的常見結構包含了一些最佳化。對效能最佳化最直接有用的最佳化如下所示。
注意:應用下面的最佳化可能會透過模糊意圖來降低程式碼的可讀性,並且可能無法在不同的 Go 版本中持續有效。請優先僅在最重要的地方應用這些最佳化。可以使用識別成本部分中列出的工具來識別這些位置。
-
不包含指標的值與其他值是隔離存放的。
因此,從並非嚴格需要指標的資料結構中消除指標可能是有利的,因為這減少了 GC 對程式的快取壓力。結果是,依賴於指標值上的索引的資料結構,雖然型別不如直接使用指標明確,但效能可能會更好。這隻有在物件圖明顯複雜且 GC 花費大量時間進行標記和掃描的情況下才值得這樣做。
-
GC 會在值中的最後一個指標處停止掃描。
因此,在 struct 型別的值中,將指標欄位分組放在值的開頭可能是有利的。這隻有在應用程式明顯花費大量時間進行標記和掃描的情況下才值得這樣做。(理論上編譯器可以自動完成此操作,但尚未實現,struct 欄位是按照原始碼中編寫的順序排列的。)
此外,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 程式設定以下額外選項。
-
將
/sys/kernel/mm/transparent_hugepage/defrag
設定為defer
或defer+madvise
。
此設定控制 Linux 核心將常規頁面合併為大頁的積極程度。defer
告訴核心懶惰地在後臺合併大頁。更積極的設定可能導致記憶體受限系統中出現停頓,並常常損害應用程式延遲。defer+madvise
類似於defer
,但對系統上顯式請求大頁並需要它們來提升效能的其他應用程式更友好。 -
將
/sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_none
設定為0
。
此設定控制 Linux 核心守護程式在嘗試分配巨頁時可以分配多少額外頁面。預設設定是最大限度地激進,並且通常可以撤銷 Go 執行時為將記憶體返回給 OS 所做的工作。在 Go 1.21 之前,Go 執行時嘗試緩解預設設定的負面影響,但這會帶來 CPU 開銷。使用 Go 1.21+ 和 Linux 6.2+,Go 執行時不再改變巨頁狀態。
如果在升級到 Go 1.21.1 或更高版本時遇到記憶體使用量增加的問題,請嘗試應用此設定;這很可能會解決您的問題。作為額外的變通方法,您可以使用PR_SET_THP_DISABLE
呼叫Prctl
函式以在程序級別停用巨頁,或者您可以設定GODEBUG=disablethp=1
(將在 Go 1.21.6 和 Go 1.22 中新增)以停用堆記憶體的巨頁。請注意,GODEBUG
設定可能會在未來版本中移除。
附錄
關於 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 需要掃描記憶體的更接近的近似值,但這會在可掃描存活堆非常少但總存活堆很大的情況下導致退化行為。