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 版本之間會發生變化。有關如何識別哪些值逃逸哪些不逃逸的更多詳細資訊,請參閱消除堆分配一節。
追蹤式垃圾收集
垃圾收集可能指代許多不同的自動回收記憶體方法;例如,引用計數。在本文件的上下文中,垃圾收集指的是追蹤式垃圾收集,它透過傳遞性地跟蹤指標來識別正在使用的,即所謂的活躍物件。
讓我們更嚴謹地定義這些術語。
-
物件——物件是動態分配的一塊記憶體,包含一個或多個 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 的記憶體成本包括活躍堆記憶體、在標記階段之前分配的新堆記憶體,以及用於元資料的空間,即使與前述成本成比例,也相對較小。
第 N 個週期的 GC 記憶體成本 = 第 N-1 個週期的活躍堆 + 新堆
活躍堆記憶體是上一個 GC 週期確定為活躍的記憶體,而新堆記憶體是當前週期分配的任何記憶體,它在週期結束時可能活躍也可能不活躍。在任何給定時間點活躍的記憶體量是程式的屬性,而不是 GC 可以直接控制的。
-
GC 的 CPU 成本建模為每個週期一個固定成本,以及一個與活躍堆大小成比例的邊際成本。
第 N 個週期的 GC CPU 時間 = 每個週期的固定 CPU 時間成本 + 每位元組平均 CPU 時間成本 * 第 N 個週期中發現的活躍堆記憶體
每個週期的固定 CPU 時間成本包括每個週期發生固定次數的事情,例如為下一個 GC 週期初始化資料結構。這個成本通常很小,包含它只是為了完整性。
GC 的大部分 CPU 成本是標記和掃描,這由邊際成本捕獲。標記和掃描的平均成本取決於 GC 實現,但也取決於程式的行為。例如,更多的指標意味著更多的 GC 工作,因為 GC 至少需要訪問程式中的所有指標。連結串列和樹等結構也更難讓 GC 並行遍歷,從而增加了每位元組的平均成本。
此模型忽略了清除成本,該成本與總堆記憶體成比例,包括已死的記憶體(必須可用於分配)。對於 Go 當前的 GC 實現,清除比標記和掃描快得多,因此相比之下成本可以忽略不計。
這個模型雖然簡單但有效:它準確地分類了 GC 的主要成本。它還告訴我們,垃圾收集器的總 CPU 成本取決於給定時間段內的 GC 週期總數。最後,這個模型中隱含著 GC 基本的時間/空間權衡。
為了理解原因,讓我們探討一個受約束但有用的場景:穩態。從 GC 的角度來看,應用程式的穩態由以下屬性定義:
-
應用程式分配新記憶體的速度(位元組/秒)是恆定的。
這意味著,從 GC 的角度來看,應用程式的工作負載隨著時間的推移大致相同。例如,對於 Web 服務,這將是一個恆定的請求速率,平均而言,發出相同型別的請求,並且每個請求的平均生命週期大致保持不變。
-
GC 的邊際成本是恆定的。
這意味著物件圖的統計資料,例如物件大小的分佈、指標數量和資料結構的平均深度,在每個週期都保持不變。
我們來舉個例子。假設某個應用程式以 10 MiB/秒的速度分配記憶體,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 也可以透過設定 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 庫臨時需要使用大量記憶體。
-
如果 Go 程式可能與其他程式共享其有限的記憶體,並且這些程式通常與 Go 程式解耦,則不要將 GOGC 設定為關閉並使用記憶體限制。相反,保留記憶體限制,因為它可能有助於抑制不良的瞬態行為,但將 GOGC 設定為在平均情況下較小且合理的值。
雖然嘗試為共享程式“預留”記憶體可能很誘人,但除非程式完全同步(例如 Go 程式呼叫某個子程序並在其被呼叫者執行時阻塞),否則結果將不可靠,因為不可避免地兩個程式都需要更多記憶體。讓 Go 程式在不需要時使用更少記憶體將總體上產生更可靠的結果。此建議也適用於超額承諾情況,即在一臺機器上執行的容器的記憶體限制總和可能超過機器實際可用的物理記憶體。
-
在部署到您無法控制的執行環境時,請勿使用記憶體限制,尤其是當程式的記憶體使用量與其輸入成比例時。
一個很好的例子是 CLI 工具或桌面應用程式。在不清楚程式可能被輸入何種資料,或者系統上有多少記憶體可用時,將記憶體限制硬編碼到程式中可能會導致令人困惑的崩潰和糟糕的效能。此外,高階終端使用者如果願意,總是可以設定記憶體限制。
-
當程式已經接近其環境的記憶體限制時,請勿設定記憶體限制以避免記憶體不足情況。
這實際上是將記憶體不足的風險替換為嚴重的應用程式效能下降的風險,這通常不是一個有利的權衡,即使 Go 努力緩解抖動。在這種情況下,更有效的方法是增加環境的記憶體限制(然後可能設定記憶體限制)或降低 GOGC(這提供了比抖動緩解更清晰的權衡)。
延遲
本文件中的視覺化模型將應用程式建模為在 GC 執行時暫停。確實存在以這種方式行為的 GC 實現,它們被稱為“全域性暫停(stop-the-world)”GC。
然而,Go GC 並非完全全域性暫停,並且大部分工作是與應用程式併發執行的。這主要是為了降低應用程式的延遲。具體來說,是單個計算單元(例如,一個 Web 請求)的端到端持續時間。到目前為止,本文件主要考慮應用程式的吞吐量(例如,每秒處理的 Web 請求)。請注意,GC 週期部分的每個示例都側重於執行程式的總 CPU 持續時間。然而,這樣的持續時間對於 Web 服務來說意義遠小於吞吐量。雖然吞吐量對於 Web 服務仍然很重要(即每秒查詢數),但通常每個獨立請求的延遲更為重要。
就延遲而言,全域性暫停 GC 可能需要相當長的時間來執行其標記和清除階段,在此期間,應用程式(以及在 Web 服務上下文中,任何正在處理的請求)無法進一步進展。相反,Go GC 避免使任何全域性應用程式暫停的長度與堆的大小成比例,並且核心追蹤演算法在應用程式主動執行時執行。(暫停在演算法上與 GOMAXPROCS 更強相關,但最常見的是由停止執行 goroutine 所需的時間主導。)併發收集並非沒有代價:在實踐中,它通常會導致設計比同等的全域性暫停垃圾收集器具有更低的吞吐量。然而,重要的是要注意,更低的延遲並不意味著固有的更低的吞吐量,並且 Go 垃圾收集器的效能在延遲和吞吐量方面都隨著時間的推移穩步提高。
Go 當前 GC 的併發特性並沒有使本文件中迄今為止討論的任何內容失效:沒有任何陳述依賴於這種設計選擇。GC 頻率仍然是 GC 在 CPU 時間和記憶體之間進行吞吐量權衡的主要方式,事實上,它也承擔著延遲方面的這一角色。這是因為 GC 的大部分成本是在標記階段活躍時發生的。
那麼,關鍵在於:降低 GC 頻率也可能帶來延遲改進。這不僅適用於透過修改調優引數(例如增加 GOGC 和/或記憶體限制)來降低 GC 頻率,也適用於最佳化指南中描述的最佳化。
然而,延遲通常比吞吐量更難理解,因為它是由程式逐秒執行的結果,而不僅僅是成本的彙總。因此,延遲與 GC 頻率之間的聯絡不那麼直接。以下是針對那些傾向於深入挖掘的人的可能延遲來源列表。
- GC 在標記和清除階段之間轉換時的短暫全域性暫停,
- 排程延遲,因為 GC 在標記階段會佔用 25% 的 CPU 資源,
- 使用者 Goroutine 響應高分配速率協助 GC,
- 當 GC 處於標記階段時,指標寫入需要額外的工作,並且
- 執行中的 goroutine 必須暫停才能掃描其根。
這些延遲源在執行跟蹤中可見,但指標寫入需要額外工作除外。
終結器、清理和弱指標
垃圾收集用有限的記憶體提供了無限記憶體的幻覺。記憶體被分配但從不顯式釋放,這與裸露的手動記憶體管理相比,實現了更簡單的 API 和併發演算法。(一些具有手動管理記憶體的語言使用替代方法,如“智慧指標”和編譯時所有權跟蹤,以確保物件被釋放,但這些特性深植於這些語言的 API 設計約定中。)
只有活躍物件——那些從全域性變數或某個 goroutine 中的計算可達的物件——才能影響程式的行為。在物件變得不可達(“死亡”)之後的任何時間,GC 都可以安全地回收它。這允許各種各樣的 GC 設計,例如 Go 今天使用的追蹤設計。物件的死亡在語言層面不是一個可觀察的事件。
然而,Go 的執行時庫提供了三個打破這種幻覺的特性:清理、弱指標和終結器。這些特性中的每一個都提供了一些觀察和響應物件死亡的方法,並且在終結器的情況下,甚至可以逆轉死亡。這當然使 Go 程式複雜化,並給 GC 實現增加了額外的負擔。儘管如此,這些特性之所以存在,是因為它們在各種情況下都很有用,Go 程式一直在使用它們並從中受益。
有關每個功能的詳細資訊,請參閱其包文件(runtime.AddCleanup、weak.Pointer、runtime.SetFinalizer)。以下是使用這些功能的一些一般建議,概述了每個功能可能遇到的常見問題,以及測試這些功能使用的建議。
一般建議
-
編寫單元測試。
清理、弱指標和終結器的確切時機難以預測,很容易說服自己一切正常,即使在多次連續執行之後也是如此。但也很容易犯細微的錯誤。為它們編寫測試可能很棘手,但考慮到它們的使用如此微妙,測試比平時更重要。
-
避免在典型的 Go 程式碼中直接使用這些特性。
這些是低階特性,具有微妙的限制和行為。例如,不能保證清理或終結器會在程式退出時執行,甚至根本不會執行。其 API 文件中的長篇註釋應被視為警告。絕大多數 Go 程式碼不會直接使用這些特性而受益,只會間接受益。
-
將這些機制的使用封裝在一個包中。
在可能的情況下,不要讓這些機制的使用洩露到您包的公共 API 中;提供難以或不可能讓使用者誤用的介面。例如,不要要求使用者設定清理一些 C 分配的記憶體來釋放它,而是編寫一個包裝包並將該細節隱藏在內部。
-
將對具有終結器、清理和弱指標的物件的訪問限制在建立和應用它們的包中。
這與前一點相關,但值得明確指出,因為這是一種以不易出錯的方式使用這些功能的非常強大的模式。例如,unique 包在底層使用弱指標,但完全封裝了弱指標指向的物件。這些值永遠不能被應用程式的其餘部分修改,只能透過Value 方法複製,為包使用者保留了無限記憶體的幻覺。
-
在可能的情況下,優先確定性地清理非記憶體資源,並將終結器和清理作為備用方案。
清理和終結器非常適合記憶體資源,例如外部(例如 C)分配的記憶體,或對
mmap
對映的引用。由 C 的 malloc 分配的記憶體最終必須由 C 的 free 釋放。附加到 C 記憶體的包裝物件的呼叫free
的終結器是確保 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.
終結器具有明確的執行順序,但清理函式沒有。清理函式也可以彼此併發執行。
長時間執行的清理程式應建立 goroutine 以避免阻塞其他清理程式的執行。
runtime.GC
不會等待不可達物件的清理函式執行完畢,只會等待它們全部排隊。
常見的弱指標問題
-
弱指標的
Value
方法可能在意外的時間開始返回nil
。始終使用nil
檢查保護對Value
的呼叫,並制定備用計劃。 -
當弱指標用作對映鍵時,它們不影響對映值的可達性。因此,如果弱指標對映鍵指向一個也從對映值可達的物件,則該物件仍將被視為可達。
常見的終結器問題
-
附帶終結器的物件不得透過任何路徑從自身可達(換句話說,它們不能處於引用迴圈中)。這將阻止物件被回收,並阻止終結器執行。
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
來修改您返回的物件的終結器,這可能是一種意外行為,您的包使用者最終可能會依賴它。
長時間執行的終結器應建立新的 goroutine 以避免阻塞其他終結器的執行。
runtime.GC
不會等待不可達物件的終結器執行完畢,只會等待它們全部排隊。
測試物件生命週期結束
當使用這些功能時,編寫使用它們的測試程式碼有時會很棘手。以下是一些為使用這些功能的程式碼編寫健壯測試的技巧。
- 避免將此類測試與其他測試並行執行。這有助於儘可能提高確定性,並在任何給定時間都能很好地掌握世界狀態。
- 在進入測試時使用
runtime.GC
建立基線。使用runtime.GC
強制弱指標為nil
,並排隊清理和終結器以執行。 -
runtime.GC
不會等待清理和終結器執行,它只會將它們排隊。為了編寫最健壯的測試,請注入一種從測試中阻塞清理或終結器的方法(例如,將可選通道傳遞給測試中的清理和/或終結器,並在執行完成後寫入通道)。如果這太困難或不可能,替代方法是在特定的清理後狀態上旋轉以使其為真。例如,
os
測試在迴圈中呼叫runtime.Gosched
,該迴圈檢查檔案在變得不可達後是否已關閉。 -
如果編寫使用終結器的測試,並且您有一個使用終結器的物件鏈,那麼您將需要至少等於測試可以建立的最深鏈的
runtime.GC
呼叫次數,以確保所有終結器都執行。 -
在競態模式下測試,以發現併發清理之間以及清理和終結器程式碼與程式碼庫其餘部分之間的競態。
其他資源
雖然上面介紹的資訊是準確的,但它缺乏詳細資訊,無法完全理解 Go GC 設計中的成本和權衡。有關更多資訊,請參閱以下附加資源。
- GC 手冊——關於垃圾收集器設計的優秀通用資源和參考資料。
- TCMalloc——C/C++ 記憶體分配器 TCMalloc 的設計文件,Go 記憶體分配器基於它。
- Go 1.5 GC 公告——宣佈 Go 1.5 併發 GC 的部落格文章,其中詳細描述了該演算法。
- 深入 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 效能分析。CPU 效能分析提供了 CPU 時間花費在哪裡的概述,儘管對於未經訓練的眼睛來說,可能難以識別 GC 在特定應用程式中扮演的角色有多大。幸運的是,理解 GC 如何融入其中,主要歸結為知道 `runtime` 包中不同函式意味著什麼。下面是用於解釋 CPU 配置檔案的這些函式的一個有用子集。
請注意,下面列出的函式不是葉子函式,因此它們可能不會出現在
pprof
工具使用top
命令提供的預設結果中。相反,請使用top -cum
命令或直接對這些函式使用list
命令,並重點關注累積百分比列。 -
runtime.gcBgMarkWorker
:後臺標記工作 goroutine 的入口點。此處花費的時間與 GC 頻率以及物件圖的複雜性和大小成比例。它表示應用程式用於標記和掃描的基準時間。請注意,在這些 goroutine 中,您會發現對
runtime.gcDrainMarkWorkerDedicated
、runtime.gcDrainMarkWorkerFractional
和runtime.gcDrainMarkWorkerIdle
的呼叫,它們指示了 worker 型別。在一個大部分空閒的 Go 應用程式中,Go GC 將會使用額外的(空閒)CPU 資源來更快地完成其工作,這由runtime.gcDrainMarkWorkerIdle
符號指示。因此,此處的時間可能代表 CPU 樣本的很大一部分,Go GC 認為這些樣本是空閒的。如果應用程式變得更加活躍,空閒 worker 中的 CPU 時間將減少。發生這種情況的一個常見原因是,如果應用程式完全在一個 goroutine 中執行,但GOMAXPROCS
> 1。 -
runtime.mallocgc
:堆記憶體分配器的入口點。此處花費的累積時間過長(>15%)通常表明分配了大量記憶體。 -
runtime.gcAssistAlloc
:Goroutine 進入此函式,將部分時間讓給 GC 進行掃描和標記。此處花費的累積時間過長(>5%)表示應用程式的分配速度可能超過了 GC。這表明 GC 的影響特別大,也代表了應用程式用於標記和掃描的時間。請注意,這包含在runtime.mallocgc
呼叫樹中,因此它也會使其膨脹。 -
執行跟蹤
雖然 CPU 配置檔案對於識別聚合時間花費在哪裡非常有用,但它們對於指示更微妙、罕見或與延遲相關的效能成本不太有用。另一方面,執行跟蹤提供了 Go 程式執行短視窗的豐富而深入的檢視。它們包含各種與 Go GC 相關的事件,並且可以直接觀察特定的執行路徑,以及應用程式可能如何與 Go GC 互動。所有跟蹤的 GC 事件都在跟蹤檢視器中方便地標記為 GC 事件。
有關如何開始使用執行跟蹤,請參閱
runtime/trace
包的文件。 -
GC 追蹤
當所有其他方法都失敗時,Go GC 提供了一些不同的特定跟蹤,這些跟蹤提供了對 GC 行為更深入的見解。這些跟蹤總是直接列印到 STDERR,每個 GC 週期一行,並透過所有 Go 程式都識別的
GODEBUG
環境變數進行配置。它們主要用於除錯 Go GC 本身,因為它們需要對 GC 實現的細節有一定的熟悉,但偶爾仍然可以用於更好地瞭解 GC 行為。核心 GC 追蹤透過設定
GODEBUG=gctrace=1
啟用。此追蹤產生的輸出在runtime
包文件的環境變數部分中進行了說明。一個補充的 GC 跟蹤,稱為“步調跟蹤”,提供了更深入的見解,透過設定
GODEBUG=gcpacertrace=1
啟用。解釋此輸出需要理解 GC 的“步調器”(參見附加資源),這超出了本指南的範圍。
消除堆分配
減少 GC 成本的一種方法是讓 GC 從一開始就管理更少的值。下面描述的技術可以產生一些最大的效能改進,因為正如GOGC 部分所演示的,Go 程式的分配速率是 GC 頻率的主要因素,這是本指南使用的關鍵成本指標。
堆分析
在識別出 GC 是顯著成本來源之後,消除堆分配的下一步是找出它們主要來自何處。為此,記憶體配置檔案(實際上是堆記憶體配置檔案)非常有用。請檢視文件以瞭解如何開始使用它們。
記憶體配置檔案描述了程式中堆分配的來源,透過分配時的堆疊跟蹤來識別它們。每個記憶體配置檔案可以從四個方面細分記憶體。
inuse_objects
——細分活躍物件的數量。inuse_space
——按物件使用的記憶體量(位元組)細分活躍物件。alloc_objects
——細分自 Go 程式開始執行以來分配的物件數量。alloc_space
——細分自 Go 程式開始執行以來分配的總記憶體量。
在 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]
此資訊也可以在支援 LSP 的編輯器中以疊加層形式視覺化;它作為程式碼操作公開。例如,在 VS Code 中,呼叫“源操作... > 顯示編譯器最佳化詳細資訊”命令,以啟用當前包的診斷。(您還可以執行“Go:切換編譯器最佳化詳細資訊”命令。)使用此配置設定來控制顯示哪些註釋:
- 透過將
ui.diagnostic.annotations
設定為包含escape
來啟用逃逸分析的疊加層。
最後,Go 編譯器以機器可讀(JSON)格式提供此資訊,可用於構建額外的自定義工具。有關更多資訊,請參閱Go 原始碼中的文件。
特定於實現的最佳化
Go GC 對活躍記憶體的分佈很敏感,因為複雜的物件和指標圖既限制了並行性,又為 GC 產生了更多工作。因此,GC 包含一些針對特定常見結構的最佳化。下面列出了對效能最佳化最直接有用的那些。
注意:應用以下最佳化可能會透過模糊意圖來降低程式碼的可讀性,並且可能無法在 Go 版本之間保持不變。僅在最重要的地方應用這些最佳化。這些地方可以透過使用識別成本一節中列出的工具來識別。
-
無指標值與其他值隔離。
因此,從不需要指標的資料結構中消除指標可能是有利的,因為這減少了 GC 對程式施加的快取壓力。因此,依賴於指標值上的索引的資料結構,雖然型別不太嚴格,但可能表現更好。這隻在物件圖複雜且 GC 花費大量時間標記和掃描時才值得做。
-
GC 將在值的最後一個指標處停止掃描值。
因此,將指標欄位在結構體型別值中分組在值的開頭可能是有利的。這隻在應用程式花費大量時間標記和掃描時才值得做。(理論上編譯器可以自動執行此操作,但尚未實現,並且結構體欄位按照原始碼中的書寫方式排列。)
此外,GC 必須與它看到的幾乎所有指標互動,因此使用切片索引(例如)而不是指標,有助於降低 GC 成本。
Linux 透明巨頁(THP)
當程式訪問記憶體時,CPU 需要將它使用的虛擬記憶體地址轉換為物理記憶體地址,這些地址指向它試圖訪問的資料。為此,CPU 會查詢“頁表”,這是一個表示虛擬記憶體到物理記憶體對映的資料結構,由作業系統管理。頁表中的每個條目都代表一個不可分割的物理記憶體塊,稱為頁,因此得名。
透明巨頁(THP)是 Linux 的一個特性,它透明地將支援連續虛擬記憶體區域的物理記憶體頁替換為更大的記憶體塊,稱為巨頁。透過使用更大的塊,表示相同記憶體區域所需的頁表條目更少,從而提高了頁表查詢時間。然而,如果巨頁只有一小部分被系統使用,更大的塊意味著更多的浪費。
在生產環境中執行 Go 程式時,在 Linux 上啟用透明巨頁可以提高吞吐量和延遲,但代價是會增加記憶體使用量。堆較小的應用程式通常不會從 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 執行時為將記憶體返回給作業系統所做的工作。在 Go 1.21 之前,Go 執行時試圖緩解預設設定的負面影響,但這帶來了 CPU 成本。在 Go 1.21+ 和 Linux 6.2+ 中,Go 執行時不再修改巨頁狀態。
如果您在升級到 Go 1.21.1 或更高版本時遇到記憶體使用量增加的情況,請嘗試應用此設定;它很可能會解決您的問題。作為額外的解決方法,您可以呼叫Prctl
函式並使用PR_SET_THP_DISABLE
在程序級別停用巨頁,或者您可以設定GODEBUG=disablethp=1
(將在 Go 1.21.6 和 Go 1.22 中新增)以停用堆記憶體的巨頁。請注意,GODEBUG
設定可能會在未來的版本中移除。
附錄
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 成本將減半。(請注意,本指南中的視覺化模擬了固定成本,因此它們報告的 GC CPU 開銷在 GOGC 加倍時不會精確減半。)此外,GC CPU 成本主要由分配速率和掃描記憶體的每位元組成本決定。有關如何具體降低這些成本的更多資訊,請參閱最佳化指南。
注意:即時堆的大小與 GC 實際需要掃描的記憶體量之間存在差異:相同大小但結構不同的即時堆會導致不同的 CPU 成本,但記憶體成本相同,從而導致不同的權衡。這就是為什麼堆結構是穩態定義的一部分。堆目標理論上應該只包含可掃描的即時堆,作為 GC 需要掃描記憶體的更接近的近似值,但這在可掃描即時堆非常小而即時堆本身很大的情況下會導致退化行為。