Go 垃圾回收器指南

簡介

本指南旨在幫助高級 Go 語言用戶更好地瞭解 Go 語言垃圾回收器的使用成本。它還提供了 Go 用戶如何利用這些知識來提高應用程序的資源利用率的指導。它並不假設你瞭解垃圾回收,但假設你熟悉 Go 語言。

Go 語言負責安排 Go 語言值的存儲。在大多數情況下,Go 語言開發人員根本不需要關心這些值存儲在哪裏,或者爲什麼要存儲。然而,在實踐中,這些值通常需要存儲在計算機物理內存中,而物理內存是有限的資源。因爲內存是有限的,所以必須小心地管理和回收內存,以避免在執行 Go 語言程序時耗盡內存。Go 語言的工作就是根據需要分配和回收內存。

自動回收內存的另一個說法是垃圾回收。從較高的層次上講,垃圾回收器(或簡稱爲 GC)是一個系統,這個系統通過標識內存的哪些部分不再需要來代表應用程序回收內存。Go 語言的標準工具鏈提供了一個運行時庫,它隨每個應用程序一起提供,並且這個運行時庫包含了一個垃圾回收器。

請注意,Go 語言規範並不能保證本指南所描述的垃圾回收器的存在,只不過 Go 語言本身負責管理 Go 語言值的底層存儲。這一省略是有意的,並允許使用完全不同的內存管理技術。

因此,本指南是關於 Go 語言的一個具體實現的指導,可能不適用於其他實現。具體來說,本指南適用於標準工具鏈(gc Go compiler 和工具)。Gccgo 和 Gollvm 都使用非常相似的 GC 實現,因此許多相同的概念都適用,但細節可能會有所不同。

此外,這是一個一直在修正的文檔,隨着時間的推移而變化,以最好地反映 Go 語言的最新版本。本文檔目前描述的是 Go 語言 1.19 中的垃圾回收器。

價值所在

在深入研究 GC 之前,讓我們首先討論一下不需要由 GC 管理的內存。

例如,存儲在局部變量中的非指針 Go 語言的值可能根本不會被 Go 語言的 GC 管理,Go 語言會安排內存的分配,並將其綁定到創建它的詞法作用域 [1] 中。一般來說,這比依賴 GC 更有效率,因爲 Go 語言編譯器能夠預先確定何時釋放內存,併發出清理內存的機器指令。通常,我們把這種爲 Go 語言的值分配內存的方式稱爲 “棧分配”,因爲空間存儲在 goroutine 棧中。

如果 Go 語言的值不能以這種方式分配內存,則 Go 語言編譯器無法確定它的生存期,那麼這些值就被稱爲 “逃逸到堆”。“堆” 可以被認爲是內存分配的一個大雜燴,Go 語言的值需要被放置在堆的某個地方。在堆上分配內存的操作通常稱爲 “動態內存分配”,因爲編譯器和運行庫都很少會對如何使用內存以及何時可以清理內存做出假設。這就是 GC 的用武之地:它是一個專門標識和清理動態內存分配的系統。

Go 語言的值需要逃逸到堆中的原因有很多。一個原因可能是其大小是動態確定的。例如,考慮一個切片的支持數組,它的初始大小由一個變量而不是一個常量確定。請注意,逃逸到堆也必須是可傳遞的:如果一個 Go 值的引用被寫入到另一個已經被確定爲逃逸的 Go 值中,那麼這個值也必須逃逸。

Go 語言的值是否逃逸取決於使用它的上下文和 Go 語言編譯器的逃逸分析算法。當價值觀逃逸時,試圖準確地列舉它將是脆弱和困難的:算法本身相當複雜,並且在不同的 Go 語言版本中會有所變化。有關如何識別哪些值逃逸而哪些值不逃逸的詳細信息,請參閱消除堆分配 [2] 一節。

跟蹤垃圾回收

垃圾回收可能指自動回收內存的衆多實現方法,例如引用計數。在本文檔的上下文中,垃圾回收指的是跟蹤垃圾回收,其通過循着指針來標識正在使用的、所謂的活動對象。

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

對象和指向其他對象的指針一起形成對象圖。爲了識別活動內存,GC 從程序的開始遍歷對象圖,程序明確使用的對象的指針。根的兩個例子是局部變量和全局變量。遍歷對象圖的過程被稱爲掃描

此基本算法對所有跟蹤 GC 通用。跟蹤 GC 的不同之處在於,一旦它們發現內存是活的,它們會做什麼。Go 語言的 GC 使用了標記 (mark)清除 (sweep) 技術,這意味着爲了跟蹤它的過程,GC 也會將它遇到的值標記爲活動的。跟蹤完成後,GC 將遍歷堆中的所有內存,並使所有未標記的對象的內存設置爲可用於分配的內存。此過程稱爲 ** 掃描 (scanning)**。

您可能熟悉的另一種技術是將對象實際移動到內存的新部分,並留下一個轉發指針,以後將使用該指針更新應用程序的所有指針。我們稱以這種方式移動對象的 GC 爲移動 GC; Go 的 GC 不是這樣子的,它是非移動 GC

GC 循環

由於 Go GC 是一個標記—清除 GC,因此它大致分爲兩個階段:標記階段清掃階段。雖然這句話似乎是重複的,但它包含了一個重要的見解:在跟蹤完所有內存之前,不可能釋放內存以供分配,因爲可能仍有未掃描的指針使對象保持活動狀態。因此,清掃動作必須與標記動作完全分開。此外,當沒有與 GC 相關的工作要做時,GC 也可能根本不活動。GC 在離開 (off)、標記和掃描這三種狀態之間不斷循環,這就是所謂的 GC 循環。

接下來的幾個章節我們將集中討論如何直觀地瞭解 GC 的成本,以幫助用戶調整 GC 參數,從而爲自己謀福利。

瞭解成本

GC 本質上是一個構建在更復雜系統上的複雜軟件。當試圖理解 GC 並調整其行爲時,很容易陷入細節的泥潭。本節旨在提供一個框架,用於說明 Go GC 的開銷和調優參數。

開始討論前,先了解基於四個簡單公理的 GC 成本模型。

  1. 在 GC 執行時,應用程序會暫停。

  2. GC 只涉及兩種資源:CPU 時間和物理內存。

  3. GC 的內存開銷包括活動堆內存、標記階段之前分配的新堆內存,以及元數據空間(即使與前兩個的開銷成比例,但相比之下元數據空間開銷也很小)。

注意:活動堆內存是由上一個 GC 週期確定爲活動的內存,而新堆內存是在當前週期中分配的任何內存,在結束時可能是活動的,也可能不是活動的。

  1. GC 的 CPU 成本被建模爲每個週期的固定成本,以及與活動堆的大小成比例的邊際成本 (marginal cost)。

注意:從漸進的角度來說,清掃的伸縮性比標記和掃描要差,因爲它必須執行與整個堆的大小成比例的工作,包括被確定爲非活動(即 “死”)的內存。然而,在當前的實現中,清掃操作比標記和掃描快得多,因此在本討論中可以忽略其相關成本。

這種模型簡單而有效:它準確地對 GC 的主要成本進行了分類。然而,這個模型沒有說明這些成本的規模,也沒有說明它們是如何相互作用的。爲了對此建模,考慮以下情況,我們稱這種場景爲穩態 (steady-stat)。

注意:重要的是要理解這個分配率與這個新內存是否是活動的完全無關。沒有一個是活的,所有的都是活的,或者一部分是活的都有可能。(除此之外,一些舊的堆內存也可能死亡,因此,如果該內存是活動的,活動堆大小不一定會增長。) 更具體地說,假設有一個 web 服務爲它處理的每個請求分配 2 MiB 的總堆內存。在請求過程中,2 MiB 中最多有 512 KiB 在請求進行期間保持活動狀態,當服務完成對請求的處理時,所有這些內存都會死亡。穩定的請求流(比如每秒 100 個請求)會產生 200 MiB/s 的分配率和 50 MiB 的峯值活動堆。

注意:穩態可能看起來是人爲的,但它的確代表了應用程序在某個恆定工作負載下的行爲。當然,在應用程序執行時,工作負載也可能發生變化,但通常應用程序行爲看起來總體上像是一串穩定狀態,中間穿插着一些瞬態行爲。

注意:穩定狀態對活動堆沒有任何假設。它可能會隨着每個後續 GC 週期而增長,可能會縮小,也可能會保持不變。然而,試圖在下面的解釋中包含所有這些情況很無聊乏味,而且不是很有說明性,所以本指南將重點放在活動堆保持不變的示例上。GOGC 一節會更詳細地探討了非常量活動堆的場景。

在活動堆大小不變的穩定狀態下,只要 GC 在經過相同的時間後執行,每個 GC 週期在成本模型中看起來都是相同的。這是因爲在固定的時間內,如果應用程序的分配速率是固定的,則將分配固定數量的新堆內存。因此,在活動堆大小不變的情況下,新的堆大小

在活動堆大小不變的穩定狀態下,只要 GC 在經過相同的時間後執行,每個 GC 週期在成本模型中看起來都是相同的。這是因爲在固定的時間內,如果應用程序的分配速率是固定的,則將分配固定數量的新堆內存。因此,在活動堆大小和新堆內存保持不變的情況下,內存使用量將始終保持不變。而且因爲活動堆的大小相同,所以邊際 GC CPU 成本也相同,並且固定成本將以某個固定間隔發生。

現在考慮 GC 如果延遲,發生在稍後時間應該運行的點之後, 因此將分配更多的內存,但每個 GC 週期仍將導致相同的 CPU 開銷。但是,在其他固定的時間窗口中,完成的 GC 週期會更少,從而降低了總體 CPU 成本。如果 GC 決定提前啓動,則情況正好相反:將分配較少的內存並且將更頻繁地引起 CPU 成本。

這種情況代表了 GC 可以在 CPU 時間和內存之間進行的基本權衡,由 GC 實際執行的頻率來控制。換句話說,折衷完全由 GC 的頻率定義。

還有一個細節需要定義,那就是 GC 應該決定何時開始。注意,這直接設置了任何特定穩態下的 GC 頻率,從而定義了折衷。在 Go 語言中,決定 GC 何時啓動是用戶可以控制的主要參數。

GOGC

GOGC 是 Go GC 的一個調優參數,它通過控制 GC 頻率直接反映了 CPU 時間和內存之間的平衡。更具體地說,GOGC 設置 GC 的目標堆大小,或者在標記階段完成之前應該分配的新內存量。GOGC 被定義爲 GC 需要完成的工作量的百分比開銷。這項工作目前被定義爲活動堆的大小加上 GC 根的大小(以字節爲單位)。

舉個例子,假設一個 Go 語言程序,它有 8 MiB 的堆,1 MiB 的 goroutine 棧,1 MiB 的全局變量指針。如果 GOGC 值爲 100,則在下一次 GC 運行之前將分配的新內存量將爲 10 MiB,或 10 MiB 工作量的 100%,總堆佔用量爲 18 MiB。如果 GOGC 值爲 50,則它將爲 50%,即分配的新內存量爲 5 MiB。如果 GOGC 值爲 200,則爲 200%,即分配的新內存量 20 MiB。

注意:GOGC 可以更精確地描述爲定義在下一個掃描階段開始之前可以分配的新內存量。從技術上講,這個記時對於本指南目前使用的 GC 模型來說是正確的,但是它也適用於 Go 語言使用的真實 GC 實現,在延遲一節中會有更詳細的討論。

以這種方式定義權衡 (trade-off) 的好處是,無論 GC 必須完成的工作量如何(也就是說,無論活動堆和根集的大小如何),GC 的成本在穩態下都保持不變,因爲頻率總是與必須完成的工作量成比例。換句話說,它代表了 CPU 成本和內存使用之間權衡的一個固定點。(需要注意的是,如果穩定狀態也發生變化,則此固定點也可能發生偏移,但關鍵是它不依賴於活動堆的大小。)

注意:GOGC 自 Go 1.18 開始包含根集, 以前它只對活動堆進行計數。通常,goroutine 堆棧中的內存量非常小,並且活動堆的大小支配着 GC 的所有其他工作來源, (所以先前的計算大概也沒問題,) 但是當程序有幾十萬個 goroutine 時,GC 會做出錯誤的判斷。

GOGC 可以通過 GOGC 環境變量(所有 Go 語言程序都能識別)或者runtime/debug包中的SetGCPercent API 來配置。

請注意,GOGC 也可用於通過設置GOGC=off或調用SetGCPercent(-1)來完全關閉 GC(前提是 memory limit 沒有使用)。從概念上講,此設置等效於將 GOGC 設置爲無窮大值,因爲在觸發 GC 之前新內存的數量是無限的。

爲了更好地理解我們到目前爲止討論的所有內容,請嘗試下面的交互式可視化,它是基於前面討論的 GC 成本模型構建的。該可視化描述了某個程序的執行,該程序的非 GC 工作需要 10 秒的 CPU 時間才能完成。在進入穩定狀態之前的第一秒,它執行一些初始化步驟(增長其活動堆)。應用程序總共分配 200 MiB,其中 20 MiB 一次處於活動狀態。它假設要完成的唯一相關 GC 工作來自活動堆,並且(不現實地)應用程序不使用額外的內存。

使用滑塊調整 GOGC 的值,以查看應用程序在總持續時間和 GC 開銷方面的響應情況。每次 GC 循環都會在新堆降爲零時發生。X 軸移動以始終顯示程序的完整 CPU 持續時間。請注意,GC 使用的額外 CPU 時間會增加總持續時間。

請注意,GC 總是會導致一些 CPU 和峯值內存開銷。隨着 GOGC 的增加,這些 CPU 開銷降低,但峯值內存與活動堆大小成比例增加。隨着 GOGC 的減小,峯值內存需求也會減少,但會增加額外的 CPU 開銷。

注意:圖形顯示的是 CPU 時間,而不是完成程序所需的掛鐘時間 (wall-clock time)。如果程序在 1 個 CPU 上運行並充分利用其資源,則它們是等效的。真實的的程序可能運行在多核系統上,並且不會始終 100% 地利用 CPU。在這些情況下,GC 的掛鐘時間影響會比較低。

注意:Go GC 的最小總堆大小爲 4 MiB,因此如果 GOGC 設置的目標值低於該值,則會取整。這個圖形展示反映此細節。

這裏有一個動態的和更有真實感的例子。同樣,在沒有 GC 的情況下,應用程序需要 10 個 CPU 秒才能完成,但在中途,穩態分配率急劇增加,並且活動堆大小在第一階段發生了一些變化。這個示例演示了當活動堆大小實際上發生變化時,穩定狀態可能是什麼樣子的,以及更高的分配率如何導致更頻繁的 GC 週期。

內存限制 (memory limit)

在 Go 1.19 之前,GOGC 是唯一一個可以用來修改 GC 行爲的參數。雖然它作爲一種設置權衡 (trade-off) 的方式非常有效,但它沒有考慮到可用內存是有限的。考慮當活動堆大小出現短暫峯值時會發生什麼情況:因爲 GC 將選擇與活動堆大小成比例的總堆大小,所以 GOGC 必須被配置爲峯值活動堆大小相匹配的值,即使在通常情況下,較高的 GOGC 值會提供了更好的權衡效果。

下面的可視化演示了這種瞬態堆峯值情況。

如果示例工作負載在可用內存略高於 60 MiB 的容器中運行,則 GOGC 不能增加到 100 以上,即使其餘 GC 週期有可用內存來使用該額外內存。此外,在一些應用中,這些瞬時峯值可能是罕見的並且難以預測,從而導致偶然的、不可避免的並且可能代價高昂的內存不足情況。

這就是爲什麼在 1.19 版本中,Go 語言增加了對設置運行時內存限制的支持。內存限制可以通過所有 Go 語言程序都能識別的 GOMEMLIMIT 環境變量來配置,也可以通過runtime/debug包中的SetMemoryLimit函數來配置。

這個內存限制設置了 Go 語言運行時可以使用的最大內存總量。包含的特定內存集是runtime.MemStatsSys - 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 循環而無法取得合理的進展,稱爲系統顛簸 (thrashing)。這是特別危險的,因爲它嚴重地拖延了程序。更糟糕的是,它可能會發生在我們試圖避免使用 GOGC 的情況下:一個足夠大臨時堆尖峯會導致程序無限期地停止! 嘗試在瞬態堆峯值可視化中降低內存限制(大約 30 MiB 或更低),並注意最壞的行爲是如何從堆峯值開始的。

在許多情況下,無限期暫停比內存不足情況更糟,因爲後者往往會導致更快的失敗以便我們發現和處理。

因此,內存限制被定義爲軟限制。Go 語言運行時並不保證在任何情況下都能保持這個內存限制; 它只承諾了一些合理的努力。內存限制的放寬對於避免系統顛簸行爲至關重要,因爲它爲 GC 提供了一條出路:讓內存使用超過限制以避免在 GC 中花費太多時間。

這在內部是如何工作的?GC mitigates 設置了一個在某個時間窗口內可以使用的 CPU 時間量的上限(對於 CPU 使用中非常短的瞬時峯值,有一些滯後)。此限制當前設置爲大約 50%,具有2 * GOMAXPROCS CPU-second窗口。限制 GC CPU 時間的結果是 GC 的工作被延遲,同時 Go 程序可能會繼續分配新的堆內存,甚至超過內存限制。

50% GC CPU 限制背後的直覺是基於對具有充足可用內存的程序的最壞情況影響。在內存限制配置錯誤的情況下,它被錯誤地設置得太低,程序最多會慢 2 倍,因爲 GC 佔用的 CPU 時間不能超過 50%。

注意:此頁上的可視化不會模擬 GC CPU 限制。

建議用法

雖然內存限制是一個強大的工具,Go 語言運行時也會採取措施來減少誤用造成的最壞行爲,但謹慎使用它仍然很重要。下面是一些關於內存限制在哪些地方最有用,以及在哪些地方可能弊大於利的建議。

雖然嘗試爲共享程序 “保留” 內存是很誘人的,但除非程序完全同步(例如,Go 程序在被調用程序執行時調用某些子進程和阻塞),否則結果將不太可靠,因爲兩個程序都不可避免地需要更多內存。讓 Go 程序在不需要內存的時候使用更少的內存,總體上會產生更可靠的結果。此建議也適用於過量使用的情況,在這種情況下,在一臺計算機上運行的容器的內存限制之和可能會超過該計算機可用的實際物理內存。

這有效地將內存不足的風險替換爲嚴重的應用程序速度減慢的風險,這通常不是一個有利的交易,即使 Go 語言努力減輕系統顛簸。在這種情況下,提高環境的內存限制(然後可能設置內存限制)或降低 GOGC(這提供了比系統顛簸緩解更乾淨的權衡)將更加有效。

延遲時間

到目前爲止,本文將應用程序建模在在 GC 執行時會暫停這一公理上。確實存在這樣的 GC 實現,它們被稱爲 stop-the-world GC。

然而,Go GC 並不是完全停止工作,實際上它的大部分工作都是與應用程序同時進行的。這樣做的主要原因是它減少了應用程序延遲。具體來說,延遲是指單個計算單元(例如,web 請求)的端到端持續時間。到目前爲止,本文主要考慮應用程序吞吐量,或這些操作的聚合(例如,每秒處理的 web 請求)。請注意,GC 週期部分中的每個示例都側重於執行程序的總 CPU 持續時間。然而,這樣的持續時間對於例如 web 服務來說意義要小得多,web 服務的持續時間主要捕獲可靠性(即 uptime)而不是成本。雖然吞吐量(即每秒的查詢數)對於 web 服務仍然很重要,但通常每個單獨請求的延遲甚至更重要,因爲它與其他重要指標相關。

就延遲而言,stop-the-world GC 可能需要相當長的時間來執行其標記和掃描階段,在此期間,應用程序以及在 web 服務的上下文中的任何正在進行的請求都無法取得進一步的進展。相反,Go GC 確保了任何全局應用程序暫停的長度都不會以任何形式與堆的大小成比例,並且在應用程序主動執行的同時執行核心跟蹤算法。這種選擇並非沒有成本,因爲在實踐中,它往往會導致吞吐量較低的設計,但需要注意的是,低延遲並不必然意味着低吞吐量,即使在許多情況下,這兩者並不一致。

首先,Go GC 的併發特性可能看起來與前面介紹的成本模型有很大的不同。幸運的是,模型背後的直覺仍然適用。

雖然第一條公理不再成立,但它開始並不是那麼重要; 其餘的成本仍然如模型所描述的那樣,並且使用相同的穩態概念。因此,GC 頻率仍然是 GC 在 CPU 時間和內存吞吐量之間進行權衡的主要方式,它還承擔了延遲的角色。關於吞吐量,只要假設併發 GC 所產生的所有小開銷都發生在 GC 週期的末尾,就很容易回到模型的範圍內。關於延遲,GC 增加的延遲中的大部分特別來自標記階段處於活動狀態的時間段。因此,GC 處於標記階段的頻率越高,這些成本就越頻繁地發生,因此等待時間也跟隨 GC 頻率。

更具體地,調整 GC 參數以降低 GC 頻率也可以導致延遲改善。這意味着需要增加 GOGC 和 / 或內存限制。

然而,理解延遲通常比理解吞吐量更復雜,因爲它是程序即時執行的產物,而不僅僅是成本的聚合之物。因此,延遲和 GC 頻率之間的聯繫更加脆弱,可能不那麼直接。下面是一個可能導致延遲的來源列表,供那些傾向於深入研究的人使用。這些延遲源在執行跟蹤中是可見的。

其他資源

雖然上面提供的信息是準確的,但它缺乏充分理解 Go GC 設計中的成本和權衡的細節。有關詳細信息,請參閱以下其他資源。

關於虛擬內存注意事項

本指南主要關注 GC 的物理內存使用,但經常出現的一個問題是你到底想說個啥,以及它與虛擬內存的比較(通常在像 top 這樣的程序中表示爲 “VSS”)。

物理內存是大多數計算機中實際物理 RAM 芯片中的內存。虛擬內存是由操作系統提供的物理內存上的抽象,用於將程序彼此隔離。程序保留完全不映射到任何物理地址的虛擬地址空間通常也是可以接受的。

由於虛擬內存只是操作系統維護的映射,因此保留不映射到物理內存的大型虛擬內存通常非常便宜。

Go 語言運行時通常在以下幾個方面依賴於這種虛擬內存開銷視圖:

因此,虛擬內存指標,比如 top 中的 “VSS”,在理解 Go 語言程序的內存佔用方面通常不是很有用。相反,應該關注“RSS” 和類似的度量,它們更直接地反映了物理內存的使用情況。

優化指南

確定成本

在嘗試優化 Go 語言應用程序與 GC 的交互方式之前,首先確定 GC 是一個主要的開銷,這一點很重要。

Go 生態系統提供了大量的工具來識別成本和優化 Go 應用程序。有關這些工具的簡要概述,請參閱診斷指南 [12]。在這裏,我們將重點討論這些工具的一個子集,以及應用它們的合理順序,以便理解 GC 的影響和行爲。

** 1、CPU profile**

優化程序的一個很好的起點是 CPU profiling[13]。CPU profiling 提供了 CPU 時間花費在何處的概述,儘管對於未經訓練的眼睛來說,可能很難確定 GC 在特定應用程序中所起作用的大小。幸運的是,理解 profile 的 GC 主要歸結爲了解runtime包中不同函數的含義即可。以下是這些函數中用於解釋 CPU profile 文件的有用子集。

注意:下面列出的函數不是葉函數,因此它們可能不會顯示在 pprof 工具爲 top 命令提供的默認值中。相反,使用top cum命令或直接對這些函數使用list命令,並將注意力集中在累計百分比列上。

注意:在一個大部分時間都處於空閒狀態的 Go 應用程序中,Go GC 會消耗額外的(空閒的)CPU 資源來更快地完成任務。結果,該符號可以表示它認爲是免費採樣部分。一個常見的原因是,一個應用程序完全在一個 goroutine 中運行,但是 GOMAXPROCS > 1。

2、執行跟蹤

雖然 CPU profile 文件非常適合用於確定時間在聚合中的花費點,但對於指示更細微、更罕見或與延遲具體相關的性能成本,它們的用處不大。另一方面,執行跟蹤提供了 Go 語言程序執行的一個短窗口的豐富而深入的視圖。它們包含了與 Go GC 相關的各種事件,可以直接觀察到具體的執行路徑,沿着應用程序與 Go GC 的交互方式。所有被跟蹤的 GC 事件都在跟蹤查看器中被方便地標記爲 GC 事件。

有關如何開始使用執行跟蹤的信息,請參閱 runtime/trace[14] 的文檔。

** 3、GC 跟蹤 **

當所有其他方法都失敗時,Go GC 還提供了一些不同的特定跟蹤,這些跟蹤提供了對 GC 行爲的更深入的瞭解。這些蹤跡總是被直接打印到 STDERR 中,每個 GC 循環一行,並且通過所有 Go 語言程序都能識別的 GODEBUG 環境變量來配置。它們主要用於調試 Go GC 本身,因爲它們需要對 GC 實現的細節有一定的瞭解,但是偶爾也可以用於更好地理解 GC 的行爲。

通過設置GODEBUG=gctrace=1,可以啓用核心 GC 跟蹤。此跟蹤生成的輸出記錄在 runtime[15] 包文檔的環境變量部分中。

一個稱爲pacer trace的技術用來補充 GC 跟蹤,提供了更深入的見解,它通過設置GODEBUG=gcpacertrace=1來啓用。解釋這個輸出需要理解 GC 的pacer(參見其他參考資料 [16]),這超出了本指南的範圍。

消除堆分配

降低 GC 成本的一種方法是讓 GC 開始管理較少的值。下面描述的技術可以帶來一些最大的性能改進,因爲正如 GOGC 部分所展示的,Go 語言程序的分配率是 GC 頻率的一個主要因素,GC 頻率是本指南使用的關鍵成本度量。

堆分析

在確定 GC 是一個巨大開銷的來源之後,消除堆分配的下一步是找出它們中的大多數來自哪裏。爲此,內存 profile 文件(實際上是堆內存 profile 文件)非常有用。請查看文檔 [17] 以瞭解如何開始使用它們。

內存 profile 文件描述了程序堆中分配的來源,並通過分配時的堆棧跟蹤來標識它們。每個內存 profile 文件可以按四種方式分析:

在這些不同的堆內存視圖之間切換可以通過 pprof 工具的 -sample_index標誌來完成,或者在交互式使用該工具時通過sample_index選項來完成。

注意:默認情況下,內存 profile 文件只對堆對象的子集進行採樣,因此它們不會包含有關每個堆分配的信息。但是,這足以找到熱點。若要更改採樣率,請參見 runtime.MemProfileRate[18]。

爲了降低 GC 成本,alloc_space 通常是最有用的視圖,因爲它直接對應於分配率。此視圖將指示可提供最大益處的分配熱點。

逃逸分析

一旦在堆 profile 文件 [19] 的幫助下確定了候選堆分配點,如何消除它們?關鍵是要利用 Go 語言編譯器的逃逸分析,讓 Go 語言編譯器爲這個內存找到替代的、更有效的存儲空間,比如在 goroutine 棧中。幸運的是,Go 語言編譯器能夠描述爲什麼要將 Go 語言的值逃逸到堆中。有了這些知識,就變成了重新組織源代碼以改變分析結果的問題(這通常是最困難的部分,但超出了本指南的範圍)。

至於如何從 Go 語言編譯器的逃逸分析中獲取信息,最簡單的方法是通過 Go 語言編譯器支持的調試標誌,該標誌以文本格式描述了對某個包應用或未應用的所有優化。這包括值是否逃逸。嘗試下面的命令,其中package是 Go 語言包的路徑:$go build-gcflags=-m=3 軟件包

此信息也可以在 VS 代碼中可視化爲覆蓋圖。此覆蓋在 VS Code Go 插件設置中配置和啓用:

最後,Go 編譯器以機器可讀(JSON)格式提供了這些信息,可以用來構建其他定製工具。有關這方面的更多信息,請參見 Go 語言源代碼中的文檔 [22]。

基於特定實現的優化

Go GC 對活動內存的人口統計很敏感,因爲對象和指針的複雜圖既限制了並行性,又爲 GC 生成了更多的工作。因此,GC 包含了一些針對特定公共結構的優化。下面列出了對性能優化最直接有用的方法。

注意:應用下面的優化可能會因爲混淆意圖而降低代碼的可讀性,並且可能無法在 Go 語言的各個版本中保持。希望只在最重要的地方應用這些優化。可以使用確定成本一節中列出的工具來確定這些地點。

因此,從並不嚴格需要指針的數據結構中消除指針可能是有利的,因爲這減少了 GC 施加在程序上的緩存壓力。因此,依賴於指針值上的索引的數據結構雖然類型化較差,但可能執行得更好。只有當對象圖很複雜並且 GC 花費大量時間進行標記和掃描時,才值得這樣做。

因此,將結構類型值中的指針字段分組在值的開頭可能是有利的。只有當應用程序花費大量時間進行標記和掃描時,才值得這樣做。(理論上,編譯器可以自動執行此操作,但尚未實現,並且結構字段的排列方式與源代碼中所寫的相同。)

此外,GC 必須與它所看到的幾乎每個指針交互,因此,例如,使用切片中的索引而不是指針,可以幫助降低 GC 成本。

譯者著, 這篇文章, 和 Russ Cox 的那三遍關於 Go 內存的模型一樣, 裏面有衆多的未解釋的名詞,不是那麼容易進行翻譯,而 Go 語言規範和 Go 內存相對就容易理解和翻譯了。我之所以嘗試翻譯,最重要的原因想深入學習本文介紹的相關知識,疏漏之處,歡迎斧正。

Go 官方原文: A Guide to the Go Garbage Collector[23]

參考資料

[1]

詞法作用域: https://go.dev/ref/spec#Declarations_and_scope

[2]

消除堆分配: https://tip.golang.org/doc/gc-guide#Eliminating_heap_allocations

[3]

The GC Handbook: https://tip.golang.org/doc/gc-guide#:~:text=following%20additional%20resources.-,The%20GC%20Handbook,-%E2%80%94An%20excellent%20general

[4]

TCMalloc: https://google.github.io/tcmalloc/design.html

[5]

Go 1.5 GC announcement: https://go.dev/blog/go15gc

[6]

Getting to Go: https://go.dev/blog/ismmkeynote

[7]

Go 1.5 concurrent GC pacing: https://docs.google.com/document/d/1wmjrocXIWTr1JxU-3EQBI6BK6KgtiFArkG47XK73xIQ/edit

[8]

Smarter scavenging: https://github.com/golang/go/issues/30333

[9]

Scalable page allocator: https://github.com/golang/go/issues/35112

[10]

GC pacer redesign (Go 1.18): https://github.com/golang/go/issues/44167

[11]

Soft memory limit (Go 1.19): https://github.com/golang/go/issues/48409

[12]

診斷指南: https://tip.golang.org/doc/diagnostics

[13]

CPU profiling: https://pkg.go.dev/runtime/pprof#hdr-Profiling_a_Go_program

[14]

runtime/trace: https://pkg.go.dev/runtime/trace

[15]

runtime: https://pkg.go.dev/runtime#hdr-Environment_Variables

[16]

其他參考資料: https://tip.golang.org/doc/gc-guide#Additional_resources

[17]

文檔: https://pkg.go.dev/runtime/pprof#hdr-Profiling_a_Go_program

[18]

runtime.MemProfileRate: https://pkg.go.dev/runtime#pkg-variables

[19]

堆 profile 文件: https://tip.golang.org/doc/Heap_profiling

[20]

ui.codelenses 設置以包括 gc_details: https://github.com/golang/vscode-go/wiki/settings#uicodelenses

[21]

將 ui.diagnostic.annotations 設置爲包括逃逸,啓用逃逸分析的覆蓋: https://github.com/golang/vscode-go/wiki/settings#uidiagnosticannotations

[22]

Go 語言源代碼中的文檔: https://cs.opensource.google/go/go/+/master:src/cmd/compile/internal/logopt/log_opts.go;l=25;drc=351e0f4083779d8ac91c05afebded42a302a6893

[23]

A Guide to the Go Garbage Collector: https://tip.golang.org/doc/gc-guide?continueFlag=bf311ba190bf0d160b5d3461e092f0f4

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/IlQL8f4-YsWJqa27k0Bm8Q