日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當前位置: 首頁 > 编程资源 > 编程问答 >内容正文

编程问答

图解 Go GC

發布時間:2024/4/11 编程问答 29 豆豆
生活随笔 收集整理的這篇文章主要介紹了 图解 Go GC 小編覺得挺不錯的,現在分享給大家,幫大家做個參考.

轉載自曹大公眾號,不光是圖,還有動畫,讀完對 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?mainfunc?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 指的是我們的應用,即 application,我們將堆上的對象看作一個圖,跳出應用來看的話,應用的代碼就是在不停地修改這張堆對象圖里的指向關系。下面的圖可以幫我們理解 mutator 對堆上的對象的影響:

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

  • allocator 很好理解,內存分配器,應用需要內存的時候都要向 allocator 申請。allocator 要維護好內存分配的數據結構,多線程分配器要考慮高并發場景下鎖的影響,并針對性地進行設計以降低鎖沖突。

  • collector 是垃圾回收器。死掉的堆對象,不用的堆內存要由 collector 回收,最終歸還給操作系統。需要掃描內存中存活的堆對象,掃描完成后,未被掃描到的對象就是無法訪問的堆上垃圾,需要將其占用內存回收掉。

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

mutator, allocator 和 collector 的交互過程

分配內存

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

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

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

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

tcmalloc 的全局圖

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

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

  • tiny :size < 16 bytes && has no pointer(noscan)

  • small :has pointer(scan) || (size >= 16 bytes && size <= 32 KB)

  • large :size > 32 KB

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

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

L1L2L3L4
mcache.tinymcache.alloc[]mheap.centralmheap.arenas

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

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

L1L2L3
mcache.alloc[]mheap.centralmheap.arenas

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 語言使用三色抽象作為其并發標記的實現,首先要理解三種顏色抽象:

  • 黑:已經掃描完畢,子節點掃描完畢。(gcmarkbits = 1,且在隊列外)

  • 灰:已經掃描完畢,子節點未掃描完畢。(gcmarkbits = 1, 在隊列內)

  • 白:未掃描,collector 不知道任何相關信息。

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

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 Insertion Barrier,指針修改時,指向的新對象要標灰

Dijistra 插入屏障

  • Yuasa Deletion Barrier,指針修改時,修改前指向的對象要標灰

Yuasa 刪除屏障

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

Go 的真實屏障實現

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

proposal 上聲稱的混合屏障實現

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

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

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

  • Dijistra Insertion Barrier 的對象丟失問題

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

  • Yuasa Deletion 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:

  • 一個叫 sweep.g,主要負責清掃死對象,合并相關的空閑頁

  • 一個叫 scvg.g,主要負責向操作系統歸還內存

(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 的去向:

  • 如果 mspan 中存活對象數 = 0,即所有 element 都變成了內存垃圾,那執行 freeSpan -> 歸還組成該 mspan 所使用的頁,并更新全局的頁分配器摘要信息

  • 如果 mspan 中沒有空槽,說明所有對象都是存活的,將其放入 fullSwept 隊列中

  • 如果 mspan 中有空槽,說明這個 mspan 還可以拿來做內存分配,將其放入 partialSweep 隊列中

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

  • bgscavenge -> pageAlloc.scavenge -> pageAlloc.scavengeOne -> pageAlloc.scavengeRangeLocked -> sysUnused -> madvise

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

問題分析

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

  • 無分代

  • 與應用執行并發

  • 協助標記流程

  • 并發執行時開啟 write barrier

因為無分代,當我們遇到一些需要在內存中保留幾千萬 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/

總結

以上是生活随笔為你收集整理的图解 Go GC的全部內容,希望文章能夠幫你解決所遇到的問題。

如果覺得生活随笔網站內容還不錯,歡迎將生活随笔推薦給好友。