Go GC:瞭解便利背後的開銷

  1. 簡介

當今,移動互聯網和人工智能的快 (越) 速(來)發 (越) 展(卷),對編程語言的高效性和便利性提出了更高的要求。Go 作爲一門高效、簡潔、易於學習的編程語言,受到了越來越多開發者的青睞。

Go 語言的垃圾回收機制(Garbage Collection,簡稱 GC)是其重要的運行機制之一,它可以幫助開發人員避免手動管理內存的複雜性和錯誤,爲開發者帶來開發上的便利,使開發者可以更專注於業務邏輯的實現。然而,GC 的便利性背後也帶來了一定的系統開銷,作爲成熟的 Go 開發者,我們需要了解 GC 帶來的開銷和優化方法,以幫助我們更好的瞭解和使用 Go 語言。

瞭解 Go GC 的原理是瞭解 GC 開銷的前提條件,我們首先來簡要看看 Go GC 的原理。

  1. Go GC 的簡明原理

Go 語言的垃圾回收器採用了併發三色標記清除算法(Concurrent Tri-Color Mark-And-Sweep),儘可能減少 STW(stop the world) 時間,以降低吞吐爲代價換取低延遲,實現了高效的垃圾回收。

標記清除算法的基本原理是,垃圾回收器將所有的存活對象標記爲 “活” 的,未被標記的對象則被認爲是垃圾。經典的標記清除算法通常分爲兩個階段:

Go 語言的垃圾回收器採用了三色標記法 (Tri-Color Marking),將堆上的內存對象分爲三種顏色:

垃圾回收器開始工作時不存在黑色對象,垃圾回收器會將根對象標記爲灰色,並從根對象 (通常是棧對象和全局對象) 開始遍歷。垃圾回收器會將灰色對象標記爲黑色,並將該對象指向的對象標記爲灰色。垃圾回收器重複這個過程,直到所有可達對象都被標記爲黑色。最後,垃圾回收器清除所有未被標記爲黑色的對象,即清除所有白色對象。

前面提到過,Go 語言的 GC 採用了併發標記的技術,以減少 GC 對系統性能的影響。併發標記指的是在 GC 運行時程序仍然可以繼續運行,而不必停止程序的執行。爲了避免程序修改對象時對標記的影響,GC 會利用混合寫屏障技術,在對象被修改時進行特殊標記 (若程序修改黑色對象 (已被掃描完畢,不會再掃描),使之指向白色對象時,寫屏障技術會將白色對象標記爲灰色,避免白色對象被釋放導致黑色對象出現懸掛指針的情況)。寫屏障技術可以有效避免併發標記階段的錯誤標記,但也會帶來一定的性能開銷

  1. GC 的開銷

從上面的 Go GC 原理來看,GC 在帶來便利的同時,開銷是不可避免的。

3.1 GC 開銷的主要來源

GC 開銷的主要來源包括以下幾個:

Go 誕生初期,GC 的實現不是很成熟,STW 時間很長,這讓很對想使用 Go 在生產上作爲一番的開發人員打了 “退堂鼓”。Go 1.5 版本 [1] 自舉後,GC 的 STW 時間大幅下降,又經過幾個版本的打磨後,STW 時間已經被 Go 降低到很短了,通常情況下都在 1 毫秒以內,甚至可以到幾十微秒,STW 時間的大幅縮短讓 Go 真正走進了生產環境。

不過再短的 STW 對於程序執行來說也是開銷,因爲 STW 期間,所有屬於業務邏輯的代碼都無法向前推進 (make progress)。

那麼一個 GC 週期究竟會做幾次 STW 呢?這裏借用 “Go 語言原本”[2] 中的一個表格:

這個表格描述了 Go 垃圾回收器主要包含的五個階段,我們看到雖然採用了併發三色標記和清除,但在一次 GC 週期內,還是要有 2 次 STW,一次是結束標記,關閉寫屏障,另一次是爲下一個週期的併發標記做準備,開啓寫屏障。

STW 時間依然是 GC 開銷的主要來源之一。減少 STW 時間對於優化 GC 的性能依然至關重要,尤其是任意場景下都要保證儘可能短暫的 STW,但這是 Go core 團隊的任務。

在標記與清除階段,GC 需要遍歷堆內存中的所有對象,並進行標記和清除,這也是十分消耗 cpu 的工作。

GC 的併發標記並非只是由特定 (dedicated) goroutine 去完成的,爲了保證 GC 標記清掃的速度不低於業務 goroutine 分配內存的速度,保證程序不因消耗內存過快過大而被 OS OOM(Out Of Memory) Killed,GC 引入標記輔助技術,即讓每個業務 goroutine 都有機會參與到 GC 標記工作中來!並且,這種標記輔助採用的是一種補償機制,即該業務 goroutine 分配的內存越多,它要輔助標記的內存就越多。一旦某個業務 goroutine 被“拉壯丁” 執行標記輔助工作,那麼該 goroutine 的業務執行就會暫停,業務邏輯也就無法向前推進。

當 Go GC 回收了堆內存之後,如果堆的大小變得比之前小了,那麼垃圾回收器會向操作系統歸還多餘的內存空間。在 Linux 等操作系統中,操作系統會將這些內存頁標記爲 “未使用”,但是這些內存頁並不會立即返回給操作系統,而是留給程序使用,以便程序將來再次申請內存時可以直接使用已經分配的內存頁,從而減少內存分配的時間和開銷。當程序沒有使用這些內存頁一段時間後,操作系統會將這些內存頁回收,並將它們標記爲 “可用”,並在需要時重新分配給程序。這個過程是由操作系統的虛擬內存管理機制來完成的,具體的開銷取決於操作系統的實現和硬件的性能等因素。

3.2 度量 GC 的開銷

由於標記輔助技術的存在,單純地從每個 GC cycle 的執行時間以及 GC 間隔時間來度量 GC 開銷似乎就不那麼準確了,更爲直觀的反映 GC 開銷的是 GC 消耗 cpu 的佔比

不過目前上沒有特別好的工具可以特別直觀且直接告訴你當前 Go 程序執行時 GC CPU 佔用率。我們可以通過 pprof 工具或類似 Pyroscope[3] 這樣的持續 profiling 的圖形化工具來間接查看 GC 的 cpu 佔用。

比如:通過 Pyroscope 提供的火焰圖,查看 runtime.gcBgMarkWorker(runtime 後臺專用的用於 GC 標記階段的 goroutine 執行的函數) 和 runtime.gcAssistAlloc(標記輔助時調用的函數) 的 cpu 消耗時間。

更爲完整的 Go runtime metrics 指標,可以查看 metrics 包的文檔 [4]。

注:GODEBUG=gctrace=1 可以輸出關於每個 GC 週期的詳細信息,關於詳細信息中各個字段的解讀可以參見這裏 [5]。更高級的選手還可以使用 Go execution tracer 工具 [6] 來剖析 GC 的開銷。

GC 的 CPU 開銷佔比通常在 25% 以下,一旦超過這個負荷比例,就要考慮做調優了,Go 保證 GC cpu 佔用不會超過 50%[7]。

  1. 優化 GC 的開銷

優化 GC 的開銷是提高系統性能和響應速度的重要手段。

前面我們分析了 Go GC 開銷的主要來源。下面就針對每種來源說說優化開銷的可能性與手段。

4.1 縮短 STW 時間

我們知道一旦 GC STW 後,所有業務邏輯都將暫停,這期間的 CPU 由 GC 100% 佔用,降低 STW 時間是降低 gc cpu 佔比的好方法。不過 STW 的算法是 Go 核心團隊把控的,降低每個 GC 週期的 STW 時間也是 Go 核心團隊的不二職責。從用戶層面是很難影響到單次 STW 時間的。

不過,我們可以通過減少 GC 次數來間接減少 STW 次數,從而降低 GC CPU 佔比。當然減少 GC 次數對後面的所有優化手段都有效,這是一個總開關。

那麼如何減少 GC 次數呢?我們先來了解 GC 的觸發時機。Go GC 觸發時機大體分爲三種:

我們看到,這三種觸發時機我們能干預的只有常規觸發,而常規觸發的公式中,可以調整的只有 GOGC 這個參數 (等價於 debug.SetGCPercent())。GOGC 默認值爲 100,也就是說當新分配 heap 內存的數量是上一週期的活躍 heap 內存的一倍的時候,觸發 GC:

如果我們將 GOGC 改爲 200,那麼 GC 的觸發間隔將增加,頻度會下降,CPU 開銷會降低 (6.4%->3.8%),如下圖:

不過這是以整個程序的內存開銷增大爲代價的 (40MB -> 60MB),並且對一般開發者而言,GOGC 的值改起來確有風險,稍有不慎可能就會觸發 OMM killed。之前 uber 曾發表一篇文章,講述了 uber 是如何通過在線自動調整 GOGC 參數來大幅降低 CPU 資源開銷的 [8],可以一看。

當然除了 GOGC 這一個唯一可調參數外,Go 社區在降低 GC 頻率方面也有自己的小妙招,比如之前經常使用的 ballast(壓艙石) 技術 [9]。其原理就是在程序初始化時先分配一塊大內存:

func main() {

 // Create a large heap allocation of 10 GiB
 ballast := make([]byte, 10<<30)

 // Application execution continues
 // ...
 runtime.KeepAlive(ballast) // make sure the ballast won't be collected 
}

這塊內存僅體現在 VSZ 中,即該程序進程的虛擬內存中,但並不佔用程序進程的常駐內存 (RSS) 中。但一旦分配,Go GC 就會將其算作是一個 “活” 堆內存對象,在計算下一次 GC 時就會將其作爲上述公式中的 live heap 考量。如果 ballast 爲 10GB,那麼 GC 就會在程序每新分配 10GB 內存時纔會被觸發。

注:RSS 是這個進程目前在主內存(RAM)中擁有多少內存。VSZ 是該進程總共有多少虛擬內存。

Go 1.19 版本 [10] 引入了 Soft memory limit[11],這個方案在 runtime/debug 包中添加了一個名爲 SetMemoryLimit 的函數以及 GOMEMLIMIT 環境變量,通過他們任意一個都可以設定 Go 應用的 Memory limit。

一旦設定了 Memory limit,當 Go 堆大小達到 “Memory limit 減去非堆內存後的值” 時,一輪 GC 會被觸發。即便你手動關閉了 GC(GOGC=off),GC 亦會被觸發。不過 soft memory limit 不保證不會出現 oom-killed。並且如果一個 Go 應用的 live heap object 超過了 soft memory limit 但還尚未被 kill,那麼此時 GC 可能會被頻繁觸發,將大量消耗 cpu 資源:

但爲了保證在這種情況下業務依然能繼續進行,soft memory limit 方案保證 GC 最多隻會使用 50% 的 CPU 算力,以保證業務處理依然能夠得到 cpu 資源。

那麼多大的值是合理的 soft memory limit 值呢?在 Go 服務獨佔容器資源時,一個好的經驗法則是留下額外的 5-10% 的空間。uber 在其博客中設定的 limit 爲資源上限的 70%,也是一個不錯的經驗值。

Memory Limit 被看作是 Go 官方的 ballast 替代方案,但還是不有所不同的。Memory limit 只是規定了一個上限,如果未到 memory limit,Go 的常規 GC 還是會照例執行的。GOGC=off+ soft Memory limit 下的行爲特徵與 ballast 更類似,不過將 GC 關掉的風險還是很大的,要三思而後行。

Go GC 沒有采用分代機制,每次都是 FullGC,減少 GC 次數確是降低 GC CPU 開銷的良方。不過除此之外,我們還有一個優化 GC 開銷的方法,我們繼續看。

4.2 減少堆內存的分配和釋放

GC 開銷大的根源在於 heap object 多,Go 的每輪 GC 都是 FullGC,每輪都要將所有 heap object 標記 (mark) 一遍,即便大多數 heap object 都是長期 alive 的,因此,一個直觀的降低 GC 開銷的方法就是減少 heap object 的數量,即減少 alloc

沿着這樣的思路,我們可以很直接的想出如下兩種手段:

這樣不僅利於減少分配次數,還有利於減少堆內存碎片,提高堆內存的利用率。如果整個結構體中沒有指針對象,那麼結構體的分配與釋放將更加高效,具體原因可參見我的《Go GC 如何檢測內存對象中是否包含指針》[12] 一文。

Go GC 開銷優化的一個典型手段就是內存空間重用,即建立一個池子,需要的時候從池中申請,用完後再放回池子裏,供其他 goroutine 重用。這個過程不再有分配與釋放。

Go 中最典型的重用的例子就是 sync.Pool 的使用,不過 sync.Pool 並非完全不做釋放操作,它是在一定程度上提高了重用的比例罷了。

  1. 小結

Go GC 的自動內存管理減少了內存泄漏和懸掛指針等問題。然而,GC 給開發者帶來便利的同時,開銷也是不可避免的,它會對系統的性能和響應速度產生影響。Go 開發者需要了解這些開銷。

在本文中,我們介紹了 GC 的基本原理、GC 的開銷及其主要來源,並提供了優化 GC 開銷的一些方法。

然而,要想有效地利用 GC,開發者需要了解其內部機制和算法,並根據實際情況進行調優。

除了通過 GC 參數降低 GC 頻率外,在實際編碼過程中,開發者還應該儘可能地減少對象的分配以降低 Go 每輪 FullGC 掃描對象的數量。

GC 的優化是一項長期的工作。開發者應該不斷地監控系統的性能和行爲,並根據需要進行調整和優化,以確保系統的性能和響應速度始終保持在最佳狀態。

  1. 參考資料


Gopher Daily(Gopher 每日新聞) 歸檔倉庫 - https://github.com/bigwhite/gopherdaily

我的聯繫方式:

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