Go 語言 GC 分析

這一篇是之前給極客時間 tony bai 老師專欄的供稿,經過編輯的同意,延遲幾個月後可以在我的個人號上發出~

本文內容只作瞭解,不建議作爲面試題考察。

武林祕籍救不了段錯誤

包教包會包分配

在各種流傳甚廣的 C 語言葵花寶典裏,一般都有這麼一條神祕的規則,不能返回局部變量:

int * func(void) {
    int num = 1234;
    /* ... */
    return #
}

duang!

當函數返回後,函數的棧幀 (stack frame) 即被銷燬,引用了被銷燬位置的內存輕則數據錯亂,重則 segmentation fault。

經過了八十一難,終於成爲了 C 語言絕世高手,還是逃不過複雜的堆上對象引用關係導致的 dangling pointer:

當 B 被 free 掉之後

當 B 被 free 掉之後,應用程序依然可能會使用指向 B 的指針,這是比較典型的 dangling pointer 問題,堆上的對象依賴關係可能會非常複雜。我們要正確地寫出 free 邏輯還得先把對象圖給畫出來。

依賴人去處理這些問題是不科學,不合理的。C 和 C++ 程序員已經被折磨了數十年,不應該再重蹈覆轍了。

垃圾回收 (Garbage Collection) 也被稱爲自動內存管理技術,現代編程語言中使用相當廣泛,常見的 Java、Go、C# 均在語言的 runtime 中集成了相應的實現。

在傳統編程語言中我們需要關注對象的分配位置,要自己去選擇對象分配在堆還是棧上,但在 Go 這門有 GC 的語言中,集成了逃逸分析功能,幫助我們自動判斷對象應該在堆上還是棧上,可以使用 go build -gcflags="-m" 來觀察逃逸分析的結果:

package main

func main() {
    var m = make([]int, 10240)
    println(m[0])
}

較大的對象也會被放在堆上

執行 gcflags="-m" 的輸出可以看到發生了逃逸

若對象被分配在棧上,其管理成本較低,通過挪動棧頂寄存器就可以實現對象的分配和釋放。若分配在堆上,則要經歷層層的內存申請過程。但這些流程對用戶都是透明的,在編寫代碼時我們並不需要在意它。需要優化時,才需要研究具體的逃逸分析規則。

逃逸分析與垃圾回收結合在一起,極大地解放了程序員們的心智,我們在編寫代碼時,似乎再也沒必要去擔心內存的分配和釋放問題了。

然而一切抽象皆有成本,這個成本要麼花在編譯期,要麼花在運行期。

GC 這種方案是選擇在運行期來解決問題,不過在極端場景下 GC 本身引起的問題依然是令人難以忽視的:

圖來自網友,GC 使用了 90% 以上的 CPU 資源

上圖的場景是在內存中緩存了上億的 kv,這時 GC 使用的 CPU 甚至佔到了總 CPU 佔用的 90% 以上。簡單粗暴地在內存中緩存對象,到頭來發現 GC 成爲了 CPU 殺手。喫掉了大量的服務器資源,這顯然不是我們期望的結果。

想要正確地分析原因,就需要我們對 GC 本身的實現機制有稍微深入一些的理解。

內存管理的三個參與者

當討論內存管理問題時,我們主要會講三個參與者,mutator,allocator 和 garbage collector。

應用運行過程中會不斷修改對象的引用關係

三者的交互過程可以用下圖來表示:

mutator, allocator 和 collector 的交互過程

分配內存

應用程序使用 mmap 向 OS 申請內存,操作系統提供的接口較爲簡單,mmap 返回的結果是連續的內存區域。

mutator 申請內存是以應用視角來看問題,我需要的是某一個 struct,某一個 slice 對應的內存,這與從操作系統中獲取內存的接口之間還有一個鴻溝。需要由 allocator 進行映射與轉換,將以 “塊” 來看待的內存與以 “對象” 來看待的內存進行映射:

應用代碼中的對象與內存間怎麼做映射?

在現代 CPU 上,我們還要考慮內存分配本身的效率問題,應用執行期間小對象會不斷地生成與銷燬,如果每一次對象的分配與釋放都需要與操作系統交互,那麼成本是很高的。這需要在應用層設計好內存分配的多級緩存,儘量減少小對象高頻創建與銷燬時的鎖競爭,這個問題在傳統的 C/C++ 語言中已經有了解法,那就是 tcmalloc:

tcmalloc 的全局圖

Go 語言的內存分配器基本是 tcmalloc 的 1:1 搬運。。畢竟都是 Google 的項目。

在 Go 語言中,根據對象中是否有指針以及對象的大小,將內存分配過程分爲三類:

在內存分配過程中,最複雜的就是 tiny 類型的分配。

我們可以將內存分配的路徑與 CPU 的多級緩存作類比,這裏 mcache 內部的 tiny 可以類比爲 L1 cache,而 alloc 數組中的元素可以類比爲 L2 cache,全局的 mheap.mcentral 結構爲 L3 cache,mheap.arena 是 L4,L4 是以頁爲單位將內存向下派發的,由 pageAlloc 來管理 arena 中的空閒內存。

KqWjQD

若 L4 也沒法滿足我們的內存分配需求,便需要向操作系統去要內存了。

和 tiny 的四級分配路徑相比,small 類型的內存沒有本地的 mcache.tiny 緩存,其餘與 tiny 分配路徑完全一致。

jFJB4e

large 內存分配稍微特殊一些,沒有上面複雜的緩存流程,而是直接從 mheap.arenas 中要內存,直接走 pageAlloc 頁分配器。

頁分配器在 Go 語言中迭代了多個版本,從簡單的 freelist 結構,到 treap 結構,再到現在最新版本的 radix 結構,其查找時間複雜度也從 O(N) -> O(log(n)) -> O(1)。

在當前版本中,只需要常數時間複雜度就可以確定空閒頁組成的 radix tree 是否能夠滿足內存分配需求。若不滿足,則要對 arena 繼續進行切分,或向操作系統申請更多的 arena。

內存分配的數據結構之間的關係

arenas 以 64MB 爲單位,arenas 會被切分成以 8KB 爲單位的 page,一個或多個 page 可以組成一個 mspan,每個 mspan 可以按照 sizeclass 劃分成多個 element。

如下圖:

各種內存分配結構之間的關係,圖上省略了頁分配器的結構

每一個 mspan 都有一個 allocBits 結構,從 mspan 裏分配 element 時,只要將 mspan 中對應該 element 位置的 bit 位置一即可。其實就是將 mspan 對應的 allocBits 中的對應 bit 位置一,在代碼中有一些優化,我們就不細說了。

垃圾回收

Go 語言使用了併發標記與清掃算法作爲其 GC 實現,併發標記清掃算法無法解決內存碎片問題,而 tcmalloc 恰好一定程度上緩解了內存碎片問題,兩者配合使用相得益彰。

這並不是說 tcmalloc 完全沒有內存碎片,不信你在代碼裏搜搜 max waste

垃圾分類

語法垃圾和語義垃圾

** 語義垃圾 (semantic garbage)**,有些場景也被稱爲內存泄露

語義垃圾指的是從語法上可達 (可以通過局部、全局變量被引用) 的對象,但從語義上來講他們是垃圾,垃圾回收器對此無能爲力。

我們初始化一個 slice,元素均爲指針,每個指針都指向了堆上 10MB 大小的一個對象。

當這個 slice 縮容時,底層數組的後兩個元素已經無法再訪問了,但其關聯的堆上內存依然是無法釋放的。

碰到類似的場景,你可能需要在縮容前,先將數組元素置爲 nil。

語法垃圾 (syntactic garbage)

語法垃圾是講那些從語法上無法到達的對象,這些纔是垃圾收集器主要的收集目標。

在 allocOnHeap 返回後,堆上的 a 無法訪問,便成爲了語法垃圾。

GC 流程

Go 的每一輪迭代幾乎都會對 GC 做優化。

經過多次優化後,較新的 GC 流程如下圖:

GC 執行流程

在開始併發標記前,併發標記終止時,有兩個短暫的 stw,該 stw 可以使用 pprof 的 pauseNs 來觀測,也可以直接採集到監控系統中:

pauseNs 就是每次 stw 的時長

儘管官方聲稱 Go 的 stw 已經是亞毫秒級了,我們在高壓力的系統中仍然能夠看到毫秒級的 stw。

標記流程

Go 語言使用三色抽象作爲其併發標記的實現,首先要理解三種顏色抽象:

三色抽象主要是爲了能讓垃圾回收流程與應用流程併發執行,這樣將對象掃描過程拆分爲多個階段,而不需要一次性完成整個掃描流程。

GC 線程與應用線程大部分情況下是併發執行的

GC 掃描的起點是根對象,忽略掉那些不重要 (finalizer 相關的先省略) 的,常見的根對象可以參見下圖:

所以在 Go 語言中,從根開始掃描的含義是從 .bss 段,.data 段以及 goroutine 的棧開始掃描,最終遍歷整個堆上的對象樹。

標記過程是一個廣度優先的遍歷過程,掃描節點,將節點的子節點推到任務隊列中,然後遞歸掃描子節點的子節點,直到所有工作隊列都被排空爲止。

後臺標記 worker 的工作過程

mark 階段會將白色對象標記,並推進隊列中變成灰色對象。我們可以看看 scanobject 的具體過程:

在後臺的 mark worker 執行對象掃描,並將 ptr push 到工作隊列

在標記過程中,gc mark worker 會一邊從工作隊列 (gcw) 中彈出對象,並將其子對象 push 到工作隊列 (gcw) 中,如果工作隊列滿了,則要將一部分元素向全局隊列轉移。

我們知道堆上對象本質上是圖,會存儲引用關係互相交叉的時候,在標記過程中也有簡單的剪枝邏輯:

如果兩個後臺 mark worker 分別從 A、B 這兩個根開始標記,他們會重複標記 D 嗎?

D 是 A 和 B 的共同子節點,在標記過程中自然會減枝,防止重複標記浪費計算資源:

標記過程中通過 isMarked 判斷來進行剪枝

如果多個後臺 mark worker 確實產生了併發,標記時使用的是 atomic.Or8,也是併發安全的。

標記使用 atomic.Or8,是併發安全的

協助標記

當應用分配內存過快時,後臺的 mark worker 無法及時完成標記工作,這時應用本身需要進行堆內存分配時,會判斷是否需要適當協助 GC 的標記過程,防止應用因爲分配過快發生 OOM。

碰到這種情況時,我們會在火焰圖中看到對應的協助標記的調用棧:

協助標記會對應用的響應延遲產生影響,可以嘗試降低應用的對象分配數量進行優化。

Go 在內部是通過一套記賬還賬系統來實現協助標記的流程的,因爲不是本文的重點,所以暫且略過。

對象丟失問題

前面提到了 GC 線程 / 協程與應用線程 / 協程是併發執行的,在 GC 標記 worker 工作期間,應用還會不斷地修改堆上對象的引用關係,下面是一個典型的應用與 GC 同時執行時,由於應用對指針的變更導致對象漏標記,從而被 GC 誤回收的情況。

如圖所示,在 GC 標記過程中,應用動態地修改了 A 和 C 的指針,讓 A 對象的內部指針指向了 B,C 的內部指針指向了 D,如果標記過程無法感知到這種變化,最終 B 對象在標記完成後是白色,會被錯誤地認作內存垃圾被回收。

爲了解決漏標,錯標的問題,我們先需要定義 “三色不變性”,如果我們的堆上對象的引用關係不管怎麼修改,都能滿足三色不變性,那麼也不會發生對象丟失問題。

強三色不變性 (strong tricolor invariant),禁止黑色對象指向白色對象。

強三色不變性

弱三色不變性 (weak tricolor invariant),黑色對象可以指向白色對象,但指向的白色對象,必須有能從灰色對象可達的路徑。

弱三色不變性

無論應用在與 GC 併發執行期間如何修改堆上對象的關係,只要修改之後,堆上對象能滿足任意一種不變性,就不會發生對象的丟失問題。

而實現強 / 弱三色不變性均需要引入屏障技術。在 Go 語言中,使用寫屏障,即 write barrier 來解決上述問題。

write barrier

barrier 本質是 : snippet of code insert before pointer modify。

在併發編程領域也有 memory barrier,但其含義與 GC 領域完全不同,在閱讀相關材料時,請注意不要混淆。

Go 語言的 GC 只有 write barrier,沒有 read barrier。

在應用進入 GC 標記階段前的 stw 階段,會將全局變量 runtime.writeBarrier.enabled 修改爲 true,這時所有的堆上指針修改操作在修改之前便會額外調用 runtime.gcWriteBarrier:

在指針修改時被插入的 write barrier 函數調用

在反彙編結果中,我們可以通過行數找到原始的代碼位置:

用行數找到真正的代碼實現

常見的 write barrier 有兩種:

Dijistra 插入屏障

Yuasa 刪除屏障

Go 的寫屏障混合了上述兩種屏障:

Go 的真實屏障實現

這和 Go 語言在混合屏障的 proposal 上的實現不太相符,本來 proposal 是這麼寫的:

proposal 上聲稱的混合屏障實現

但棧的顏色判斷成本是很高的,所以官方還是選擇了更爲簡單的實現,即指針斷開的老對象和新對象都標灰的實現。

如果 Go 語言的所有對象都在堆上分配,理論上我們只要選擇 Dijistra 或者 Yuasa 中的任意一種,就可以實現強 / 弱三色不變性了,爲什麼要做這麼複雜呢?

因爲在 Go 語言中,由於棧上對象操作過於頻繁,即使在標記執行階段,棧上對象也是不開啓寫屏障的。如果我們只使用 Dijistra 或者只使用 Yuasa Barrier,都會有對象丟失的問題:

棧上的黑色對象會指向堆上的白色對象

堆上的黑色對象會指向堆上的白色對象

早期 Go 只使用了 Dijistra 屏障,但因爲會有上述對象丟失問題,需要在第二個 stw 週期進行棧重掃 (stack rescan)。當 goroutine 數量較多時,會造成 stw 時間較長。

想要消除棧重掃,但單獨使用任意一種 barrier 都沒法滿足 Go 的要求,所以最新版本中 Go 的混合屏障其實是 Dijistra Insertion Barrier  + Yuasa Deletion Barrier。

混合 barrier 的實現

混合 write barrier 會將兩個指針推到 p 的 wbBuf 結構去,我們來看看這個過程:

混合 barrier 會將指針推進 P 的 wbBuf 結構中,滿了就往 gcw 推

現在我們可以看看 mutator 和後臺的 mark worker 在併發執行時的完整過程了:

mutator 和 mark worker 同時在執行時

回收流程

相比複雜的標記流程,對象的回收和內存釋放就簡單多了。

進程啓動時會有兩個特殊 goroutine:

(dlv) goroutines
* Goroutine 1 - User: ./int.go:22 main.main (0x10572a6) (thread 5247606)
  Goroutine 2 - User: /usr/local/go/src/runtime/proc.go:367 runtime.gopark (0x102e596) [force gc (idle) 455634h24m29.787802783s]
  Goroutine 3 - User: /usr/local/go/src/runtime/proc.go:367 runtime.gopark (0x102e596) [GC sweep wait]
  Goroutine 4 - User: /usr/local/go/src/runtime/proc.go:367 runtime.gopark (0x102e596) [GC scavenge wait]

注意看這裏的 GC sweep wait 和 GC scavenge wait, 就是這兩個 goroutine

當 GC 的標記流程結束之後,sweep goroutine 就會被喚醒,進行清掃工作,其實就是循環執行 sweepone -> sweep。針對每個 mspan,sweep.g 的工作是將標記期間生成的 bitmap 替換掉分配時使用的 bitmap:

mspan:用標記期間生成的 bitmap 替換掉分配內存時使用的 bitmap

然後根據 mspan 中的槽位情況決定該 mspan 的去向:

之後 “清道夫” 被喚醒,執行線性流程,一路運行到將頁內存歸還給操作系統:

最終還是要用 madvise 來將內存歸還給操作系統

問題分析

從前面的基礎知識中,我們可以總結出 Go 語言垃圾回收的關鍵點:

因爲無分代,當我們遇到一些需要在內存中保留幾千萬 kv map 的場景 (比如機器學習的特徵系統) 時,就需要想辦法降低 GC 掃描成本。

因爲有協助標記,當應用的 GC 佔用的 CPU 超過 25% 時,會觸發大量的協助標記,影響應用的延遲,這時也要對 GC 進行優化。

簡單的業務使用 sync.Pool 就可以帶來較好的優化效果,若碰到一些複雜的業務場景,還要考慮 offheap 之類的欺騙 GC 的方案,比如 dgraph 的方案 [1]。

因爲本篇聚焦於內存分配和 GC 的實現,就不展開了。

本文中涉及的所有內存管理的名詞,大家都可以在:https://memorymanagement.org 上找到。

垃圾回收的理論,推薦閱讀:《gc handbook》,可以解答你所有的疑問。

[1] dgraph 的方案: https://dgraph.io/blog/post/manual-memory-management-golang-jemalloc/

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