Golang GC 從原理到優化

這篇文章與筆者之前所寫幾篇不同,是一篇綜述型的文章,將從 GC 理論、在 Golang 中的應用、以及如何去做優化,這三個角度逐次進行闡述,文章中對於一些技術點會引用到多篇文章,希望讀者也都能進行閱讀,這有助於更全面的瞭解 Golang GC。

同時,特別鳴謝 @王德宇 同學對這篇文章的斧正,以及撰寫過程中的諸多幫助與解答。

理論

GC 和內存分配方式是強相關的兩個技術,因此在分析兩者的設計原理之時,要結合起來一起看。

GC 算法

標記 - 清除

標記 - 整理 

標記 - 複製

分代收集

關於以上算法的簡單介紹:[https://blog.csdn.net/weixin_45393094/article/details/108691358

內存分配方式

線性分配

線性分配(Bump Allocator)是一種高效的內存分配方法,但是有較大的侷限性。當我們使用線性分配器時,只需要在內存中維護一個指向內存特定位置的指針,如果用戶程序向分配器申請內存,分配器只需要檢查剩餘的空閒內存、返回分配的內存區域並修改指針在內存中的位置,即移動下圖中的指針:線性分配器雖然線性分配器實現爲它帶來了較快的執行速度以及較低的實現複雜度,但是線性分配器無法在內存被釋放時重用內存。如下圖所示,如果已經分配的內存被回收,線性分配器無法重新利用紅色的內存:線性分配器回收內存因爲線性分配器具有上述特性,所以需要與合適的垃圾回收算法配合使用,例如:標記壓縮(Mark-Compact)、複製回收(Copying GC)和分代回收(Generational GC)等算法,它們可以通過拷貝的方式整理存活對象的碎片,將空閒內存定期合併,這樣就能利用線性分配器的效率提升內存分配器的性能了。因爲線性分配器需要與具有拷貝特性的垃圾回收算法配合,所以 C 和 C++ 等需要直接對外暴露指針的語言就無法使用該策略。

引用自:

[https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-memory-allocator/#%E5%88%86%E9%85%8D%E6%96%B9%E6%B3%95]

應用代表:Java(如果使用 Serial, ParNew 等帶有 Compact 過程的收集器時,採用分配的方式爲線性分配)

問題:內存碎片

解決方式:GC 算法中加入「複製 / 整理」階段

空閒鏈表分配

空閒鏈表分配器(Free-List Allocator)可以重用已經被釋放的內存,它在內部會維護一個類似鏈表的數據結構。當用戶程序申請內存時,空閒鏈表分配器會依次遍歷空閒的內存塊,找到足夠大的內存,然後申請新的資源並修改鏈表:空閒鏈表分配器因爲不同的內存塊通過指針構成了鏈表,所以使用這種方式的分配器可以重新利用回收的資源,但是因爲分配內存時需要遍歷鏈表,所以它的時間複雜度是 O(n)。空閒鏈表分配器可以選擇不同的策略在鏈表中的內存塊中進行選擇,最常見的是以下四種:

  • 首次適應(First-Fit)— 從鏈表頭開始遍歷,選擇第一個大小大於申請內存的內存塊;

  • 循環首次適應(Next-Fit)— 從上次遍歷的結束位置開始遍歷,選擇第一個大小大於申請內存的內存塊;

  • 最優適應(Best-Fit)— 從鏈表頭遍歷整個鏈表,選擇最合適的內存塊;

  • 隔離適應(Segregated-Fit)— 將內存分割成多個鏈表,每個鏈表中的內存塊大小相同,申請內存時先找到滿足條件的鏈表,再從鏈表中選擇合適的內存塊;

引用自:[https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-memory-allocator/#%E5%88%86%E9%85%8D%E6%96%B9%E6%B3%95]

應用代表:GO、Java(如果使用 CMS 這種基於標記 - 清除,採用分配的方式爲空閒鏈表分配) 

問題:相比線性分配方式的 bump-pointer 分配操作(top += size),空閒鏈表的分配操作過重,例如在 GO 程序的 pprof 圖中經常可以看到 mallocgc() 佔用了比較多的 CPU;

在 Golang 裏的應用

完整講解:https://time.geekbang.org/column/article/484271

內存分配方式

Golang 採用了基於空閒鏈表分配方式的 TCMalloc 算法。

關於 TCMalloc 官方文檔 [https://github.com/google/tcmalloc/blob/master/docs/design.md]

  • Front-end:它是一個內存緩存,提供了快速分配和重分配內存給應用的功能。它主要由 2 部分組成:Per-thread cache 和 Per-CPU cache。

  • Middle-end:職責是給 Front-end 提供緩存。也就是說當 Front-end 緩存內存不夠用時,從 Middle-end 申請內存。它主要是 Central free list 這部分內容。

  • Back-end:這一塊是負責從操作系統獲取內存,並給 Middle-end 提供緩存使用。它主要涉及 Page Heap 內容。

TCMalloc 將整個虛擬內存空間劃分爲 n 個同等大小的 Page。將 n 個連續的 page 連接在一起組成一個 Span。PageHeap 向 OS 申請內存,申請的 span 可能只有一個 page,也可能有 n 個 page。ThreadCache 內存不夠用會向 CentralCache 申請,CentralCache 內存不夠用時會向 PageHeap 申請,PageHeap 不夠用就會向 OS 操作系統申請。

引用自:[https://www.cnblogs.com/jiujuan/p/13869547.html]

GC 算法

Golang 採用了基於併發標記與清掃算法的三色標記法。

  1. Golang GC 的四個階段

Mark Prepare - STW 做標記階段的準備工作,需要停止所有正在運行的 goroutine(即 STW),標記根對象,啓用內存屏障,內存屏障有點像內存讀寫鉤子,它用於在後續併發標記的過程中,維護三色標記的完備性 (三色不變性),這個過程通常很快,大概在 10-30 微秒。

Marking - Concurrent 標記階段會將大概 25%(gcBackgroundUtilization)的 P 用於標記對象,逐個掃描所有 G 的堆棧,執行三色標記,在這個過程中,所有新分配的對象都是黑色,被掃描的 G 會被暫停,掃描完成後恢復,這部分工作叫後臺標記 (gcBgMarkWorker)。這會降低系統大概 25% 的吞吐量,比如 MAXPROCS=6,那麼 GC P 期望使用率爲 6*0.25=1.5,這 150%P 會通過專職(Dedicated)/ 兼職(Fractional)/ 懶散(Idle) 三種工作模式的 Worker 共同來完成。這還沒完,爲了保證在 Marking 過程中,其它 G 分配堆內存太快,導致 Mark 跟不上 Allocate 的速度,還需要其它 G 配合做一部分標記的工作,這部分工作叫輔助標記(mutator assists)。在 Marking 期間,每次 G 分配內存都會更新它的” 負債指數”(gcAssistBytes),分配得越快,gcAssistBytes 越大,這個指數乘以全局的”負載匯率”(assistWorkPerByte),就得到這個 G 需要幫忙 Marking 的內存大小(這個計算過程叫 revise),也就是它在本次分配的 mutator assists 工作量(gcAssistAlloc)。

Mark Termination - STW 標記階段的最後工作是 Mark Termination,關閉內存屏障,停止後臺標記以及輔助標記,做一些清理工作,整個過程也需要 STW,大概需要 60-90 微秒。在此之後,所有的 P 都能繼續爲應用程序 G 服務了。

Sweeping - Concurrent 在標記工作完成之後,剩下的就是清理過程了,清理過程的本質是將沒有被使用的內存塊整理回收給上一個內存管理層級 (mcache -> mcentral -> mheap -> OS),清理回收的開銷被平攤到應用程序的每次內存分配操作中,直到所有內存都 Sweeping 完成。當然每個層級不會全部將待清理內存都歸還給上一級,避免下次分配再申請的開銷,比如 Go1.12 對 mheap 歸還 OS 內存做了優化,使用 NADV_FREE 延遲歸還內存。

引用自:[https://wudaijun.com/2020/01/go-gc-keypoint-and-monitor/]

  1. 關於 GC 觸發閾值

對應關係如下:

  • GC 開始時內存使用量:GC trigger;

  • GC 標記完成時內存使用量:Heap size at GC completion;

  • GC 標記完成時的存活內存量:圖中標記的 Previous marked heap size 爲上一輪的 GC 標記完成時的存活內存量;

  • 本輪 GC 標記完成時的預期內存使用量:Goal heap size;

引用自: https://www.jianshu.com/p/4069d1e3d716

存在問題

  1. GC Marking - Concurrent 階段,這個階段有三個問題:

  2.  GC 協程和業務協程是並行運行的,大概會佔用 25% 的 CPU,使得程序的吞吐量下降;

  3.  如果業務 goroutine 分配堆內存太快,導致 Mark 跟不上 Allocate 的速度,那麼業務 goroutine 會被招募去做協助標記,暫停對業務邏輯的執行,這會影響到服務處理請求的耗時。

  4.  GOGC 在穩態場景下可以很好的工作,但是在瞬態場景下,如定時的緩存失效,定時的流量脈衝,GC 影響會急劇上升。一個典型例子:IO 密集型服務 耗時優化:https://segmentfault.com/a/1190000041637173

  5. GC Mark Prepare、Mark Termination - STW 階段,這兩個階段雖然按照官方說法時間會很短,但是在實際的線上服務中,有時會在 trace 圖中觀測到長達十幾 ms 的停頓,原因可能爲:OS 線程在做內存申請的時候觸發內存整理被 “卡住”,Go Runtime 無法搶佔處於這種情況的 goroutine ,進而阻塞 STW 完成。(內存申請卡住原因:HugePage 配置不合理)

  6. 過於關注 STW 的優化,帶來服務吞吐量的下降(高峯期內存分配和 GC 時間的 CPU 佔用超過 30% );

性能問題之 GC

這裏談一下 GC 的問題,或者說內存管理的問題。

內存管理包括了內存分配和垃圾回收兩個方面,對於 Go 來說,GC 是一個併發 - 標記 - 清除(CMS)算法收集器。但是需要注意一點,Go 在實現 GC 的過程當中,過多地把重心放在了暫停時間——也就是 Stop the World(STW)的時間方面,但是代價是犧牲了 GC 中的其他特性。

我們知道,GC 有很多需要關注的方面,比如吞吐量——GC 肯定會減慢程序,那麼它對吞吐量有多大的影響;還有,在一段固定的 CPU 時間裏可以回收多少垃圾;另外還有 Stop the World 的時間和頻率;以及新申請內存的分配速度;還有在分配內存時,空間的浪費情況;以及在多核機器下,GC 能否充分利用多核等很多方面問題。非常遺憾的是,Golang 在設計和實現時,過度強調了暫停時間有限。但這帶來了其他影響:比如在執行的過程當中,堆是不能壓縮的,也就是說,對象也是不能移動的;還有它也是一個不分代的 GC。所以體現在性能上,就是內存分配和 GC 通常會佔用比較多 CPU 資源。

我們有同事進行過一些統計,很多微服務在晚高峯期,內存分配和 GC 時間甚至會佔用超過 30% 的 CPU 資源。佔用這麼高資源的原因大概有兩點,一個是 Go 裏面比較頻繁地進行內存分配操作;另一個是 Go 在分配堆內存時,實現相對比較重,消耗了比較多 CPU 資源。比如它中間有 acquired M 和 GC 互相搶佔的鎖;它的代碼路徑也比較長;指令數也比較多;內存分配的局部性也不是特別好。

引用自:https://mp.weixin.qq.com/s/0X4lasAf5Sbt_tromlqwIQ

  1. 由於 GC 不分代,每次 GC 都要掃描全量的存活對象,導致 GC 開銷較高。(解決方式:GO 的分代 GC [https://www.jianshu.com/p/2383743edb7b])

優化

強烈建議閱讀官方這篇 Go 垃圾回收指南(翻譯)[https://blog.leonard.wang/archives/gc-guide]

目標

方向

  1. 降低 GC 頻率;

  2. 減少堆上對象數量;

問題:爲什麼降低 GC 頻率可以改善延遲

關鍵的一點是,降低 GC 頻率也可能會改善延遲。這不僅適用於通過修改調整參數來降低 GC 頻率,例如增加 GOGC 和 / 或內存限制,還適用於優化指南中描述的優化。

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

  • 當 GC 在標記和掃描階段之間轉換時,短暫的 stop-the-world 暫停

  • 調度延遲是因爲 GC 在標記階段佔用了 25% 的 CPU 資源

  • 用戶 goroutine 在高內存分配速率下的輔助標記

  • 當 GC 處於標記階段時,指針寫入需要額外的處理(write barrier)

  • 運行中的 goroutine 必須被暫停,以便掃描它們的根。

引用自:https://blog.leonard.wang/archives/gc-guide

手段

sync.pool

原理: 使用 sync.pool() 緩存對象,減少堆上對象分配數;

Pool's purpose is to cache allocated but unused items for later reuse, relieving pressure on the garbage collector.

注意: sync.pool 是全局對象,讀寫存在競爭問題,因此在這方面會消耗一定的 CPU,但之所以通常用它優化後 CPU 會有提升,是因爲它的對象複用功能對 GC 和內存分配帶來的優化,因此 sync.pool 的優化效果取決於鎖競爭增加的 CPU 消耗與優化 GC 與內存分配減少的 CPU 消耗這兩者的差值;

設置 GOGC 參數(go 1.19 之前)

原理: GOGC 默認值是 100,也就是下次 GC 觸發的 heap 的大小是這次 GC 之後的 heap 的一倍,通過調大 GOGC 值(gcpercent)的方式,達到減少 GC 次數的目的;

公式:gc_trigger = heap_marked * (1+gcpercent/100)

heap_marked:上一個 GC 中被標記的 (存活的) 字節數;

gcpercent:通過 GOGC 來設置,默認是 100,也就是當前內存分配到達上次存活堆內存 2 倍時,觸發 GC;

在 go 1.19 及之後,這個公式變爲了 heap_marked + (heap_marked + GC roots) * gcpercent / 100 

GC roots:全局變量和 goroutine 的棧

存在問題: GOGC 參數不易控制,設置較小提升有限,設置較大容易有 OOM 風險,因爲堆大小本身是在實時變化的,在任何流量下都設置一個固定值,是一件有風險的事情。

ballast 內存控制(go 1.19 之前)

原理: 仍然是從利用了下次 GC 觸發的 heap 的大小是這次 GC 之後的 heap 的一倍這一原理,初始化一個生命週期貫穿整個 Go 應用生命週期的超大 slice,用於內存佔位,實際操作有以下兩種方式

公式:gc_trigger = heap_marked * (1+gcpercent/100) 

gcpercent:通過 GOGC 來設置,默認是 100,也就是當前內存分配到達上次存活堆內存 2 倍時,觸發 GC;

heap_marked:上一個 GC 中被標記的 (存活的) 字節數;

相比於設置 GOGC 的優勢

負面考量

問:雖然通過大切片佔位的方式可以有效降低 GC 頻率,但是每次 GC 需要掃描和回收的對象數量變多了,是否會導致進行 GC 的那一段時間產生耗時毛刺?

答:不會,GC 有兩個階段 mark 與 sweep,unused_objects 只與 sweep 階段有關,但這個過程是非常快速的;mark 階段是 GC 時間佔用最主要的部分,但其只與當前的 inuse_objects 有關,與 unused_objects 無太大關係;因此,綜上所述,降低頻率確實會讓每次 GC 時的 unused_objects 有所增長,但並不會對 GC 增加太多負擔;

關於 ballast 內存控制更詳細的內容:https://blog.twitch.tv/en/2019/04/10/go-memory-ballast-how-i-learnt-to-stop-worrying-and-love-the-heap/

以上三種優化操作的相關實踐: Go 語言 - 計算密集型服務 性能優化 [https://segmentfault.com/a/1190000041602269]

GCTuner(go 1.19 之前)

原理: https://eng.uber.com/how-we-saved-70k-cores-across-30-mission-critical-services/

實現: https://github.com/bytedance/gopkg/tree/develop/util/gctuner

簡述: 同上文講到的設置 GOGC 參數的思路相同,但增加了自動調整的設計,而非在程序初始設置一個固定值,可以有效避免高峯期的 OOM 問題。

優點: 不需要修改 GO 源碼,通用性較強;

**缺點:**對內存的控制不夠精準。

GO SetMemoryLimit(go 1.19 及之後)

原理: https://github.com/golang/proposal/blob/master/design/48409-soft-memory-limit.md

簡述:

通過對 Go 使用的內存總量設置軟內存限制來調整 Go 垃圾收集器的行爲。此選項有兩種形式:runtime/debug 調用的新函數 SetMemoryLimit 和 GOMEMLIMIT 環境變量。總之,運行時將嘗試通過限制堆的大小並通過更積極地將內存返回給底層平臺來維持此內存限制。這包括一種有助於減輕垃圾收集死循環的機制。最後,通過設置 GOGC=off,Go 運行時將始終將堆增長到滿內存限制。這個新選項使應用程序可以更好地控制其資源經濟性。它使用戶能夠:

  • 更好地利用他們已經擁有的內存;

  • 自信地降低他們的內存限制,知道 Go 會遵守他們;

  • 避免使用不受支持的 GC 調整方式;

效果測試:https://colobu.com/2022/06/20/how-to-use-SetMemoryLimit/

其他:

  1. 與 GCTuner 的區別:a. 兩者都是通過調節 heapgoal 和 gctrigger 的值(GC 觸發閾值),達到影響 GC 觸發時機的目的;b. GCTuner 對於 heapgoal 值的調整,依賴 SetFinalizer 的執行時機,在執行時通過設置 GOGC 參數間接調整的,在每個 GC 週期時最多調整一次;而 SetMemoryLimit 是一直在實時動態調整的,在每次檢查是否需要觸發 GC 的時候重新算的,不僅是每一輪 GC 完時決定,因此對於內存的控制更加精準。

  2. 對內存的控制非常精準,可以關注到所有由 runtime 管理的內存,包括全局 Data 段、BSS 段所佔用的內存;goroutine 棧的內存;被 GC 管理的內存;非 GC 管理的內存,如 trace、GC 管理所需的 mspan 等數據結構;緩存在 Go Heap 中沒有被使用,也暫時未歸還操作系統的內存;

  3. 一般配合 GOGC = off 一起使用,可以達到最好的效果。

Bigcache

原理: https://colobu.com/2019/11/18/how-is-the-bigcache-is-fast/ 

會在內存中分配大數組用以達到 0 GC 的目的,並使用 map[int]int,維護對對象的引用;

當 map 中的 key 和 value 都是基礎類型時,GC 就不會掃到 map 裏的 key 和 value

存在問題: 由於大數組的存在,會起到同 ballast 內存控制手段的效果,一定程度上會影響到 GC 頻率;

相關實踐: IO 密集型服務 耗時優化 [https://segmentfault.com/a/1190000041637173]

fastcache

與 bigcache 類似,但使用 syscall.mmap 申請堆外內存,避免了像 bigcache 影響 GC 的問題;

堆外分配

繞過 Go runtime 直接分配內存,使 runtime 感知不到此塊內存,從而不增加 GC 開銷。

問題:管理成本高,靈活性低;

GAB(Goroutine allocation buffer)

優化對象分配效率,並減少 GC 掃描的工作量。

經過調研發現,很多微服務進行內存分配時,分配的對象大部分都是比較小的對象。基於這個觀測,我們設計了 GAB(Goroutine allocation buffer)機制,用來優化小對象內存分配。Go 的內存分配用的是 tcmalloc 算法,傳統的 tcmalloc,會爲每個分配請求執行一個比較完整的 malloc GC 方法,而我們的 Gab 爲每個 Goroutine 預先分配一個比較大的 buffer,然後使用 bump-pointer 的方式,爲適合放進 Gab 裏的小對象來進行快速分配。我們算法和 tcmalloc 算法完全兼容,而且它的分配操作可以隨意被 Stop the world 打斷。雖然我們的 Gab 優化可能會造成一些空間浪費,但是在很多微服務上測試後,發現 CPU 性能大概節省了 5% 到 12%。

引用自:https://mp.weixin.qq.com/s/0X4lasAf5Sbt_tromlqwIQ

Go1.20 arena

官方文檔: https://github.com/golang/go/issues/51317 

簡述: 可以經由 runtime 申請內存,但由用戶手動管理此塊堆內存。因爲是經由 runtime 申請的,可以被 runtime 感知到,因此可以納入 GC 觸發條件中的內存計算裏,有效降低 OOM 風險。

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