圖解 Go GC
轉(zhuǎn)載自曹大公眾號(hào),不光是圖,還有動(dòng)畫(huà),讀完對(duì) Go GC 會(huì)有一個(gè)高層次的理解。
這一篇是之前給極客時(shí)間 tony bai 老師專欄的供稿,經(jīng)過(guò)編輯的同意,延遲幾個(gè)月后可以在我的個(gè)人號(hào)上發(fā)出~
本文內(nèi)容只作了解,不建議作為面試題考察。
武林秘籍救不了段錯(cuò)誤

包教包會(huì)包分配
在各種流傳甚廣的 C 語(yǔ)言葵花寶典里,一般都有這么一條神秘的規(guī)則,不能返回局部變量:
int?*?func(void)?{
????int?num?=?1234;
????/*?...?*/
????return?#
}
duang!
當(dāng)函數(shù)返回后,函數(shù)的棧幀(stack frame)即被銷毀,引用了被銷毀位置的內(nèi)存輕則數(shù)據(jù)錯(cuò)亂,重則 segmentation fault。
經(jīng)過(guò)了八十一難,終于成為了 C 語(yǔ)言絕世高手,還是逃不過(guò)復(fù)雜的堆上對(duì)象引用關(guān)系導(dǎo)致的 dangling pointer:

當(dāng) B 被 free 掉之后
當(dāng) B 被 free 掉之后,應(yīng)用程序依然可能會(huì)使用指向 B 的指針,這是比較典型的 dangling pointer 問(wèn)題,堆上的對(duì)象依賴關(guān)系可能會(huì)非常復(fù)雜。我們要正確地寫(xiě)出 free 邏輯還得先把對(duì)象圖給畫(huà)出來(lái)。
依賴人去處理這些問(wèn)題是不科學(xué),不合理的。C 和 C++ 程序員已經(jīng)被折磨了數(shù)十年,不應(yīng)該再重蹈覆轍了。
垃圾回收(Garbage Collection)也被稱為自動(dòng)內(nèi)存管理技術(shù),現(xiàn)代編程語(yǔ)言中使用相當(dāng)廣泛,常見(jiàn)的 Java、Go、C# 均在語(yǔ)言的 runtime 中集成了相應(yīng)的實(shí)現(xiàn)。
在傳統(tǒng)編程語(yǔ)言中我們需要關(guān)注對(duì)象的分配位置,要自己去選擇對(duì)象分配在堆還是棧上,但在 Go 這門有 GC 的語(yǔ)言中,集成了逃逸分析功能,幫助我們自動(dòng)判斷對(duì)象應(yīng)該在堆上還是棧上,可以使用 `go build -gcflags="-m"` 來(lái)觀察逃逸分析的結(jié)果:
package?main
func?main()?{
????var?m?=?make([]int,?10240)
????println(m[0])
}
較大的對(duì)象也會(huì)被放在堆上

執(zhí)行 gcflags="-m" 的輸出可以看到發(fā)生了逃逸
若對(duì)象被分配在棧上,其管理成本較低,通過(guò)挪動(dòng)棧頂寄存器就可以實(shí)現(xiàn)對(duì)象的分配和釋放。若分配在堆上,則要經(jīng)歷層層的內(nèi)存申請(qǐng)過(guò)程。但這些流程對(duì)用戶都是透明的,在編寫(xiě)代碼時(shí)我們并不需要在意它。需要優(yōu)化時(shí),才需要研究具體的逃逸分析規(guī)則。
逃逸分析與垃圾回收結(jié)合在一起,極大地解放了程序員們的心智,我們?cè)诰帉?xiě)代碼時(shí),似乎再也沒(méi)必要去擔(dān)心內(nèi)存的分配和釋放問(wèn)題了。
然而一切抽象皆有成本,這個(gè)成本要么花在編譯期,要么花在運(yùn)行期。
GC 這種方案是選擇在運(yùn)行期來(lái)解決問(wèn)題,不過(guò)在極端場(chǎng)景下 GC 本身引起的問(wèn)題依然是令人難以忽視的:

圖來(lái)自網(wǎng)友,GC 使用了 90% 以上的 CPU 資源
上圖的場(chǎng)景是在內(nèi)存中緩存了上億的 kv,這時(shí) GC 使用的 CPU 甚至占到了總 CPU 占用的 90% 以上。簡(jiǎn)單粗暴地在內(nèi)存中緩存對(duì)象,到頭來(lái)發(fā)現(xiàn) GC 成為了 CPU 殺手。吃掉了大量的服務(wù)器資源,這顯然不是我們期望的結(jié)果。
想要正確地分析原因,就需要我們對(duì) GC 本身的實(shí)現(xiàn)機(jī)制有稍微深入一些的理解。
內(nèi)存管理的三個(gè)參與者
當(dāng)討論內(nèi)存管理問(wèn)題時(shí),我們主要會(huì)講三個(gè)參與者,mutator,allocator 和 garbage collector。
mutator 指的是我們的應(yīng)用,即 application,我們將堆上的對(duì)象看作一個(gè)圖,跳出應(yīng)用來(lái)看的話,應(yīng)用的代碼就是在不停地修改這張堆對(duì)象圖里的指向關(guān)系。下面的圖可以幫我們理解 mutator 對(duì)堆上的對(duì)象的影響:

應(yīng)用運(yùn)行過(guò)程中會(huì)不斷修改對(duì)象的引用關(guān)系
allocator 很好理解,內(nèi)存分配器,應(yīng)用需要內(nèi)存的時(shí)候都要向 allocator 申請(qǐng)。allocator 要維護(hù)好內(nèi)存分配的數(shù)據(jù)結(jié)構(gòu),多線程分配器要考慮高并發(fā)場(chǎng)景下鎖的影響,并針對(duì)性地進(jìn)行設(shè)計(jì)以降低鎖沖突。 collector 是垃圾回收器。死掉的堆對(duì)象,不用的堆內(nèi)存要由 collector 回收,最終歸還給操作系統(tǒng)。需要掃描內(nèi)存中存活的堆對(duì)象,掃描完成后,未被掃描到的對(duì)象就是無(wú)法訪問(wèn)的堆上垃圾,需要將其占用內(nèi)存回收掉。
三者的交互過(guò)程可以用下圖來(lái)表示:

mutator, allocator 和 collector 的交互過(guò)程
分配內(nèi)存
應(yīng)用程序使用 mmap 向 OS 申請(qǐng)內(nèi)存,操作系統(tǒng)提供的接口較為簡(jiǎn)單,mmap 返回的結(jié)果是連續(xù)的內(nèi)存區(qū)域。
mutator 申請(qǐng)內(nèi)存是以應(yīng)用視角來(lái)看問(wèn)題,我需要的是某一個(gè) struct,某一個(gè) slice 對(duì)應(yīng)的內(nèi)存,這與從操作系統(tǒng)中獲取內(nèi)存的接口之間還有一個(gè)鴻溝。需要由 allocator 進(jìn)行映射與轉(zhuǎn)換,將以“塊”來(lái)看待的內(nèi)存與以“對(duì)象”來(lái)看待的內(nèi)存進(jìn)行映射:

應(yīng)用代碼中的對(duì)象與內(nèi)存間怎么做映射?
在現(xiàn)代 CPU 上,我們還要考慮內(nèi)存分配本身的效率問(wèn)題,應(yīng)用執(zhí)行期間小對(duì)象會(huì)不斷地生成與銷毀,如果每一次對(duì)象的分配與釋放都需要與操作系統(tǒng)交互,那么成本是很高的。這需要在應(yīng)用層設(shè)計(jì)好內(nèi)存分配的多級(jí)緩存,盡量減少小對(duì)象高頻創(chuàng)建與銷毀時(shí)的鎖競(jìng)爭(zhēng),這個(gè)問(wèn)題在傳統(tǒng)的 C/C++ 語(yǔ)言中已經(jīng)有了解法,那就是 tcmalloc:

tcmalloc 的全局圖
Go 語(yǔ)言的內(nèi)存分配器基本是 tcmalloc 的 1:1 搬運(yùn)。。畢竟都是 Google 的項(xiàng)目。
在 Go 語(yǔ)言中,根據(jù)對(duì)象中是否有指針以及對(duì)象的大小,將內(nèi)存分配過(guò)程分為三類:
tiny :size < 16 bytes && has no pointer(noscan) small :has pointer(scan) || (size >= 16 bytes && size <= 32 KB) large :size > 32 KB
在內(nèi)存分配過(guò)程中,最復(fù)雜的就是 tiny 類型的分配。
我們可以將內(nèi)存分配的路徑與 CPU 的多級(jí)緩存作類比,這里 mcache 內(nèi)部的 tiny 可以類比為 L1 cache,而 alloc 數(shù)組中的元素可以類比為 L2 cache,全局的 mheap.mcentral 結(jié)構(gòu)為 L3 cache,mheap.arena 是 L4,L4 是以頁(yè)為單位將內(nèi)存向下派發(fā)的,由 pageAlloc 來(lái)管理 arena 中的空閑內(nèi)存。
| L1 | L2 | L3 | L4 |
|---|---|---|---|
| mcache.tiny | mcache.alloc[] | mheap.central | mheap.arenas |
若 L4 也沒(méi)法滿足我們的內(nèi)存分配需求,便需要向操作系統(tǒng)去要內(nèi)存了。
和 tiny 的四級(jí)分配路徑相比,small 類型的內(nèi)存沒(méi)有本地的 mcache.tiny 緩存,其余與 tiny 分配路徑完全一致。
| L1 | L2 | L3 |
|---|---|---|
| mcache.alloc[] | mheap.central | mheap.arenas |
large 內(nèi)存分配稍微特殊一些,沒(méi)有上面復(fù)雜的緩存流程,而是直接從 mheap.arenas 中要內(nèi)存,直接走 pageAlloc 頁(yè)分配器。
頁(yè)分配器在 Go 語(yǔ)言中迭代了多個(gè)版本,從簡(jiǎn)單的 freelist 結(jié)構(gòu),到 treap 結(jié)構(gòu),再到現(xiàn)在最新版本的 radix 結(jié)構(gòu),其查找時(shí)間復(fù)雜度也從 O(N) -> O(log(n)) -> O(1)。
在當(dāng)前版本中,只需要常數(shù)時(shí)間復(fù)雜度就可以確定空閑頁(yè)組成的 radix tree 是否能夠滿足內(nèi)存分配需求。若不滿足,則要對(duì) arena 繼續(xù)進(jìn)行切分,或向操作系統(tǒng)申請(qǐng)更多的 arena。
內(nèi)存分配的數(shù)據(jù)結(jié)構(gòu)之間的關(guān)系
arenas 以 64MB 為單位,arenas 會(huì)被切分成以 8KB 為單位的 page,一個(gè)或多個(gè) page 可以組成一個(gè) mspan,每個(gè) mspan 可以按照 sizeclass 劃分成多個(gè) element。
如下圖:

各種內(nèi)存分配結(jié)構(gòu)之間的關(guān)系,圖上省略了頁(yè)分配器的結(jié)構(gòu)
每一個(gè) mspan 都有一個(gè) allocBits 結(jié)構(gòu),從 mspan 里分配 element 時(shí),只要將 mspan 中對(duì)應(yīng)該 element 位置的 bit 位置一即可。其實(shí)就是將 mspan 對(duì)應(yīng)的 allocBits 中的對(duì)應(yīng) bit 位置一,在代碼中有一些優(yōu)化,我們就不細(xì)說(shuō)了。
垃圾回收
Go 語(yǔ)言使用了并發(fā)標(biāo)記與清掃算法作為其 GC 實(shí)現(xiàn),并發(fā)標(biāo)記清掃算法無(wú)法解決內(nèi)存碎片問(wèn)題,而 tcmalloc 恰好一定程度上緩解了內(nèi)存碎片問(wèn)題,兩者配合使用相得益彰。
這并不是說(shuō) tcmalloc 完全沒(méi)有內(nèi)存碎片,不信你在代碼里搜搜 max waste。
垃圾分類

語(yǔ)法垃圾和語(yǔ)義垃圾
**語(yǔ)義垃圾(semantic garbage)**,有些場(chǎng)景也被稱為內(nèi)存泄露
語(yǔ)義垃圾指的是從語(yǔ)法上可達(dá)(可以通過(guò)局部、全局變量被引用)的對(duì)象,但從語(yǔ)義上來(lái)講他們是垃圾,垃圾回收器對(duì)此無(wú)能為力。

我們初始化一個(gè) slice,元素均為指針,每個(gè)指針都指向了堆上 10MB 大小的一個(gè)對(duì)象。

當(dāng)這個(gè) slice 縮容時(shí),底層數(shù)組的后兩個(gè)元素已經(jīng)無(wú)法再訪問(wèn)了,但其關(guān)聯(lián)的堆上內(nèi)存依然是無(wú)法釋放的。
碰到類似的場(chǎng)景,你可能需要在縮容前,先將數(shù)組元素置為 nil。
語(yǔ)法垃圾(syntactic garbage)
語(yǔ)法垃圾是講那些從語(yǔ)法上無(wú)法到達(dá)的對(duì)象,這些才是垃圾收集器主要的收集目標(biāo)。

在 allocOnHeap 返回后,堆上的 a 無(wú)法訪問(wèn),便成為了語(yǔ)法垃圾。
GC 流程
Go 的每一輪迭代幾乎都會(huì)對(duì) GC 做優(yōu)化。
經(jīng)過(guò)多次優(yōu)化后,較新的 GC 流程如下圖:

GC 執(zhí)行流程
在開(kāi)始并發(fā)標(biāo)記前,并發(fā)標(biāo)記終止時(shí),有兩個(gè)短暫的 stw,該 stw 可以使用 pprof 的 pauseNs 來(lái)觀測(cè),也可以直接采集到監(jiān)控系統(tǒng)中:

pauseNs 就是每次 stw 的時(shí)長(zhǎng)
盡管官方聲稱 Go 的 stw 已經(jīng)是亞毫秒級(jí)了,我們?cè)诟邏毫Φ南到y(tǒng)中仍然能夠看到毫秒級(jí)的 stw。
標(biāo)記流程
Go 語(yǔ)言使用三色抽象作為其并發(fā)標(biāo)記的實(shí)現(xiàn),首先要理解三種顏色抽象:
黑:已經(jīng)掃描完畢,子節(jié)點(diǎn)掃描完畢。(gcmarkbits = 1,且在隊(duì)列外) 灰:已經(jīng)掃描完畢,子節(jié)點(diǎn)未掃描完畢。(gcmarkbits = 1, 在隊(duì)列內(nèi)) 白:未掃描,collector 不知道任何相關(guān)信息。
三色抽象主要是為了能讓垃圾回收流程與應(yīng)用流程并發(fā)執(zhí)行,這樣將對(duì)象掃描過(guò)程拆分為多個(gè)階段,而不需要一次性完成整個(gè)掃描流程。

GC 線程與應(yīng)用線程大部分情況下是并發(fā)執(zhí)行的
GC 掃描的起點(diǎn)是根對(duì)象,忽略掉那些不重要(finalizer 相關(guān)的先省略)的,常見(jiàn)的根對(duì)象可以參見(jiàn)下圖:

所以在 Go 語(yǔ)言中,從根開(kāi)始掃描的含義是從 .bss 段,.data 段以及 goroutine 的棧開(kāi)始掃描,最終遍歷整個(gè)堆上的對(duì)象樹(shù)。
標(biāo)記過(guò)程是一個(gè)廣度優(yōu)先的遍歷過(guò)程,掃描節(jié)點(diǎn),將節(jié)點(diǎn)的子節(jié)點(diǎn)推到任務(wù)隊(duì)列中,然后遞歸掃描子節(jié)點(diǎn)的子節(jié)點(diǎn),直到所有工作隊(duì)列都被排空為止。

后臺(tái)標(biāo)記 worker 的工作過(guò)程
mark 階段會(huì)將白色對(duì)象標(biāo)記,并推進(jìn)隊(duì)列中變成灰色對(duì)象。我們可以看看 scanobject 的具體過(guò)程:

在后臺(tái)的 mark worker 執(zhí)行對(duì)象掃描,并將 ptr push 到工作隊(duì)列
在標(biāo)記過(guò)程中,gc mark worker 會(huì)一邊從工作隊(duì)列(gcw)中彈出對(duì)象,并將其子對(duì)象 push 到工作隊(duì)列(gcw)中,如果工作隊(duì)列滿了,則要將一部分元素向全局隊(duì)列轉(zhuǎn)移。
我們知道堆上對(duì)象本質(zhì)上是圖,會(huì)存儲(chǔ)引用關(guān)系互相交叉的時(shí)候,在標(biāo)記過(guò)程中也有簡(jiǎn)單的剪枝邏輯:

如果兩個(gè)后臺(tái) mark worker 分別從 A、B 這兩個(gè)根開(kāi)始標(biāo)記,他們會(huì)重復(fù)標(biāo)記 D 嗎?
D 是 A 和 B 的共同子節(jié)點(diǎn),在標(biāo)記過(guò)程中自然會(huì)減枝,防止重復(fù)標(biāo)記浪費(fèi)計(jì)算資源:

標(biāo)記過(guò)程中通過(guò) isMarked 判斷來(lái)進(jìn)行剪枝
如果多個(gè)后臺(tái) mark worker 確實(shí)產(chǎn)生了并發(fā),標(biāo)記時(shí)使用的是 atomic.Or8,也是并發(fā)安全的。

標(biāo)記使用 atomic.Or8,是并發(fā)安全的
協(xié)助標(biāo)記
當(dāng)應(yīng)用分配內(nèi)存過(guò)快時(shí),后臺(tái)的 mark worker 無(wú)法及時(shí)完成標(biāo)記工作,這時(shí)應(yīng)用本身需要進(jìn)行堆內(nèi)存分配時(shí),會(huì)判斷是否需要適當(dāng)協(xié)助 GC 的標(biāo)記過(guò)程,防止應(yīng)用因?yàn)榉峙溥^(guò)快發(fā)生 OOM。
碰到這種情況時(shí),我們會(huì)在火焰圖中看到對(duì)應(yīng)的協(xié)助標(biāo)記的調(diào)用棧:

協(xié)助標(biāo)記會(huì)對(duì)應(yīng)用的響應(yīng)延遲產(chǎn)生影響,可以嘗試降低應(yīng)用的對(duì)象分配數(shù)量進(jìn)行優(yōu)化。
Go 在內(nèi)部是通過(guò)一套記賬還賬系統(tǒng)來(lái)實(shí)現(xiàn)協(xié)助標(biāo)記的流程的,因?yàn)椴皇潜疚牡闹攸c(diǎn),所以暫且略過(guò)。
對(duì)象丟失問(wèn)題
前面提到了 GC 線程/協(xié)程與應(yīng)用線程/協(xié)程是并發(fā)執(zhí)行的,在 GC 標(biāo)記 worker 工作期間,應(yīng)用還會(huì)不斷地修改堆上對(duì)象的引用關(guān)系,下面是一個(gè)典型的應(yīng)用與 GC 同時(shí)執(zhí)行時(shí),由于應(yīng)用對(duì)指針的變更導(dǎo)致對(duì)象漏標(biāo)記,從而被 GC 誤回收的情況。
如圖所示,在 GC 標(biāo)記過(guò)程中,應(yīng)用動(dòng)態(tài)地修改了 A 和 C 的指針,讓 A 對(duì)象的內(nèi)部指針指向了 B,C 的內(nèi)部指針指向了 D,如果標(biāo)記過(guò)程無(wú)法感知到這種變化,最終 B 對(duì)象在標(biāo)記完成后是白色,會(huì)被錯(cuò)誤地認(rèn)作內(nèi)存垃圾被回收。

為了解決漏標(biāo),錯(cuò)標(biāo)的問(wèn)題,我們先需要定義“三色不變性”,如果我們的堆上對(duì)象的引用關(guān)系不管怎么修改,都能滿足三色不變性,那么也不會(huì)發(fā)生對(duì)象丟失問(wèn)題。
強(qiáng)三色不變性(strong tricolor invariant),禁止黑色對(duì)象指向白色對(duì)象。

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

弱三色不變性
無(wú)論應(yīng)用在與 GC 并發(fā)執(zhí)行期間如何修改堆上對(duì)象的關(guān)系,只要修改之后,堆上對(duì)象能滿足任意一種不變性,就不會(huì)發(fā)生對(duì)象的丟失問(wèn)題。
而實(shí)現(xiàn)強(qiáng)/弱三色不變性均需要引入屏障技術(shù)。在 Go 語(yǔ)言中,使用寫(xiě)屏障,即 write barrier 來(lái)解決上述問(wèn)題。
write barrier
barrier 本質(zhì)是 : snippet of code insert before pointer modify。
在并發(fā)編程領(lǐng)域也有 memory barrier,但其含義與 GC 領(lǐng)域完全不同,在閱讀相關(guān)材料時(shí),請(qǐng)注意不要混淆。
Go 語(yǔ)言的 GC 只有 write barrier,沒(méi)有 read barrier。
在應(yīng)用進(jìn)入 GC 標(biāo)記階段前的 stw 階段,會(huì)將全局變量 runtime.writeBarrier.enabled 修改為 true,這時(shí)所有的堆上指針修改操作在修改之前便會(huì)額外調(diào)用 runtime.gcWriteBarrier:

在指針修改時(shí)被插入的 write barrier 函數(shù)調(diào)用
在反匯編結(jié)果中,我們可以通過(guò)行數(shù)找到原始的代碼位置:

用行數(shù)找到真正的代碼實(shí)現(xiàn)
常見(jiàn)的 write barrier 有兩種:
Dijistra Insertion Barrier,指針修改時(shí),指向的新對(duì)象要標(biāo)灰

Dijistra 插入屏障
Yuasa Deletion Barrier,指針修改時(shí),修改前指向的對(duì)象要標(biāo)灰

Yuasa 刪除屏障
Go 的寫(xiě)屏障混合了上述兩種屏障:

Go 的真實(shí)屏障實(shí)現(xiàn)
這和 Go 語(yǔ)言在混合屏障的 proposal 上的實(shí)現(xiàn)不太相符,本來(lái) proposal 是這么寫(xiě)的:

proposal 上聲稱的混合屏障實(shí)現(xiàn)
但棧的顏色判斷成本是很高的,所以官方還是選擇了更為簡(jiǎn)單的實(shí)現(xiàn),即指針斷開(kāi)的老對(duì)象和新對(duì)象都標(biāo)灰的實(shí)現(xiàn)。
如果 Go 語(yǔ)言的所有對(duì)象都在堆上分配,理論上我們只要選擇 Dijistra 或者 Yuasa 中的任意一種,就可以實(shí)現(xiàn)強(qiáng)/弱三色不變性了,為什么要做這么復(fù)雜呢?
因?yàn)樵?Go 語(yǔ)言中,由于棧上對(duì)象操作過(guò)于頻繁,即使在標(biāo)記執(zhí)行階段,棧上對(duì)象也是不開(kāi)啟寫(xiě)屏障的。如果我們只使用 Dijistra 或者只使用 Yuasa Barrier,都會(huì)有對(duì)象丟失的問(wèn)題:
Dijistra Insertion Barrier 的對(duì)象丟失問(wèn)題

棧上的黑色對(duì)象會(huì)指向堆上的白色對(duì)象
Yuasa Deletion Barrier 的對(duì)象丟失問(wèn)題

堆上的黑色對(duì)象會(huì)指向堆上的白色對(duì)象
早期 Go 只使用了 Dijistra 屏障,但因?yàn)闀?huì)有上述對(duì)象丟失問(wèn)題,需要在第二個(gè) stw 周期進(jìn)行棧重掃(stack rescan)。當(dāng) goroutine 數(shù)量較多時(shí),會(huì)造成 stw 時(shí)間較長(zhǎng)。
想要消除棧重掃,但單獨(dú)使用任意一種 barrier 都沒(méi)法滿足 Go 的要求,所以最新版本中 Go 的混合屏障其實(shí)是 Dijistra Insertion Barrier ?+ Yuasa Deletion Barrier。

混合 barrier 的實(shí)現(xiàn)
混合 write barrier 會(huì)將兩個(gè)指針推到 p 的 wbBuf 結(jié)構(gòu)去,我們來(lái)看看這個(gè)過(guò)程:

混合 barrier 會(huì)將指針推進(jìn) P 的 wbBuf 結(jié)構(gòu)中,滿了就往 gcw 推
現(xiàn)在我們可以看看 mutator 和后臺(tái)的 mark worker 在并發(fā)執(zhí)行時(shí)的完整過(guò)程了:

mutator 和 mark worker 同時(shí)在執(zhí)行時(shí)
回收流程
相比復(fù)雜的標(biāo)記流程,對(duì)象的回收和內(nèi)存釋放就簡(jiǎn)單多了。
進(jìn)程啟動(dòng)時(shí)會(huì)有兩個(gè)特殊 goroutine:
一個(gè)叫 sweep.g,主要負(fù)責(zé)清掃死對(duì)象,合并相關(guān)的空閑頁(yè) 一個(gè)叫 scvg.g,主要負(fù)責(zé)向操作系統(tǒng)歸還內(nèi)存
(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, 就是這兩個(gè) goroutine
當(dāng) GC 的標(biāo)記流程結(jié)束之后,sweep goroutine 就會(huì)被喚醒,進(jìn)行清掃工作,其實(shí)就是循環(huán)執(zhí)行 sweepone -> sweep。針對(duì)每個(gè) mspan,sweep.g 的工作是將標(biāo)記期間生成的 bitmap 替換掉分配時(shí)使用的 bitmap:

mspan:用標(biāo)記期間生成的 bitmap 替換掉分配內(nèi)存時(shí)使用的 bitmap
然后根據(jù) mspan 中的槽位情況決定該 mspan 的去向:
如果 mspan 中存活對(duì)象數(shù) = 0,即所有 element 都變成了內(nèi)存垃圾,那執(zhí)行 freeSpan -> 歸還組成該 mspan 所使用的頁(yè),并更新全局的頁(yè)分配器摘要信息 如果 mspan 中沒(méi)有空槽,說(shuō)明所有對(duì)象都是存活的,將其放入 fullSwept 隊(duì)列中 如果 mspan 中有空槽,說(shuō)明這個(gè) mspan 還可以拿來(lái)做內(nèi)存分配,將其放入 partialSweep 隊(duì)列中
之后“清道夫”被喚醒,執(zhí)行線性流程,一路運(yùn)行到將頁(yè)內(nèi)存歸還給操作系統(tǒng):
bgscavenge -> pageAlloc.scavenge -> pageAlloc.scavengeOne -> pageAlloc.scavengeRangeLocked -> sysUnused -> madvise

最終還是要用 madvise 來(lái)將內(nèi)存歸還給操作系統(tǒng)
問(wèn)題分析
從前面的基礎(chǔ)知識(shí)中,我們可以總結(jié)出 Go 語(yǔ)言垃圾回收的關(guān)鍵點(diǎn):
無(wú)分代 與應(yīng)用執(zhí)行并發(fā) 協(xié)助標(biāo)記流程 并發(fā)執(zhí)行時(shí)開(kāi)啟 write barrier
因?yàn)闊o(wú)分代,當(dāng)我們遇到一些需要在內(nèi)存中保留幾千萬(wàn) kv map 的場(chǎng)景(比如機(jī)器學(xué)習(xí)的特征系統(tǒng))時(shí),就需要想辦法降低 GC 掃描成本。
因?yàn)橛袇f(xié)助標(biāo)記,當(dāng)應(yīng)用的 GC 占用的 CPU 超過(guò) 25% 時(shí),會(huì)觸發(fā)大量的協(xié)助標(biāo)記,影響應(yīng)用的延遲,這時(shí)也要對(duì) GC 進(jìn)行優(yōu)化。
簡(jiǎn)單的業(yè)務(wù)使用 sync.Pool 就可以帶來(lái)較好的優(yōu)化效果,若碰到一些復(fù)雜的業(yè)務(wù)場(chǎng)景,還要考慮 offheap 之類的欺騙 GC 的方案,比如?dgraph 的方案[1]。
因?yàn)楸酒劢褂趦?nèi)存分配和 GC 的實(shí)現(xiàn),就不展開(kāi)了。
本文中涉及的所有內(nèi)存管理的名詞,大家都可以在:https://memorymanagement.org 上找到。
垃圾回收的理論,推薦閱讀:《gc handbook》,可以解答你所有的疑問(wèn)。
dgraph 的方案:?https://dgraph.io/blog/post/manual-memory-management-golang-jemalloc/
