介紹
編寫 Go 代碼不需要像寫 C/C++ 那樣手動(dòng)的 malloc
和 free
內(nèi)存吃媒,因?yàn)?malloc
操作由 Go 編譯器的逃逸分析機(jī)制幫我們加上了漾月,而 free
動(dòng)作則是有 GC 機(jī)制來完成钮热。
雖說 GC 是一個(gè)很好的特性性置,大大降低了編程門檻西采,但這是以損耗性能為代價(jià)的。Go 的 GC 機(jī)制是不斷進(jìn)化提升的链沼,到現(xiàn)在也沒有停止乱豆。其進(jìn)化過程中主要有一下幾個(gè)重要的里程碑:
- 1.1 版本: 標(biāo)記+清除方式疲酌,整個(gè)過程需要 STW(stop the world怀浆,掛起所有用戶 goroutine)
- 1.3 版本: 標(biāo)記過程 STW首懈,清除過程并行
- 1.5 版本: 標(biāo)記過程使用三色標(biāo)記法
- 1.8 版本: Hibrid Write Barrier
- 未來: 類似 JVM 的分代機(jī)制?
下面詳細(xì)介紹下這整個(gè)演進(jìn)過程。
標(biāo)記清除
垃圾回收的算法很多称开,比如最常見的引用計(jì)數(shù)鳖轰,節(jié)點(diǎn)復(fù)制等等。Go 采用的是標(biāo)記清除方式。當(dāng) GC 開始時(shí),從 root 開始一層層掃描场仲,這里的 root 區(qū)值當(dāng)前所有 goroutine 的棧和全局?jǐn)?shù)據(jù)區(qū)的變量(主要是這 2 個(gè)地方)燕差。掃描過程中把能被觸達(dá)的 object 標(biāo)記出來,那么堆空間未被標(biāo)記的 object 就是垃圾了;最后遍歷堆空間所有 object 對(duì)垃圾(未標(biāo)記)的 object 進(jìn)行清除旋廷,清除完成則表示 GC 完成。清除的 object 會(huì)被放回到 mcache 中以備后續(xù)分配使用礼搁。
我在 Go 語言內(nèi)存管理(二):Go 內(nèi)存管理 提到過饶碘,Go 的內(nèi)存區(qū)域中有一個(gè) bitmap
區(qū)域,就是用來存儲(chǔ) object 標(biāo)記的馒吴。
最開始 Go 的整個(gè) GC 過程需要 STW扎运,因?yàn)橛脩暨M(jìn)程如果在 GC 過程中修改了變量的引用關(guān)系,可能會(huì)導(dǎo)致清理錯(cuò)誤饮戳。舉個(gè)例子豪治,我們假設(shè)下面的變量使用堆空間:
A := new(struct {
B *int
})
如果 GC 已經(jīng)掃描完了變量 A
,并對(duì) A
和 B
進(jìn)行了標(biāo)記扯罐,如果沒有 STW负拟,在執(zhí)行清除之前,用戶線程有可能會(huì)執(zhí)行 A.B = new(int)
歹河,那么這個(gè)新對(duì)象 new(int)
會(huì)因?yàn)闆]有標(biāo)記而被清除掩浙。
Go GC 的 STW 曾經(jīng)是大家吐槽的焦點(diǎn),因?yàn)樗?jīng)常使你的系統(tǒng)卡住秸歧,造成幾百毫秒延遲厨姚。
并行清除
這個(gè)優(yōu)化很簡(jiǎn)單,如上面所述键菱,STW 是為了阻止標(biāo)記的錯(cuò)誤遣蚀,那么只需對(duì)標(biāo)記過程進(jìn)行 STW,確保標(biāo)記正確纱耻。清除過程是不需要 STW 的。
標(biāo)記清除算法致命的缺點(diǎn)就在 STW 上险耀,所以 Golang 后期的很多優(yōu)化都是針對(duì) STW 的弄喘,盡可能縮短它的時(shí)間,避免出現(xiàn) Go 服務(wù)的卡頓甩牺。
三色標(biāo)記法
為了能讓標(biāo)記過程也能并行蘑志,Go 采用了三色標(biāo)記 + 寫屏障的機(jī)制。它的步驟大致如下:
- GC 開始時(shí)贬派,認(rèn)為所有 object 都是白色急但,即垃圾。
- 從 root 區(qū)開始遍歷搞乏,被觸達(dá)的 object 置成灰色波桩。
- 遍歷所有灰色 object,將他們內(nèi)部的引用變量置成 灰色请敦,自身置成 黑色
- 循環(huán)第 3 步镐躲,直到?jīng)]有灰色 object 了储玫,只剩下了黑白兩種,白色的都是垃圾萤皂。
- 對(duì)于黑色 object撒穷,如果在標(biāo)記期間發(fā)生了寫操作,寫屏障會(huì)在真正賦值前將新對(duì)象標(biāo)記為灰色裆熙。
- 標(biāo)記過程中端礼,
mallocgc
新分配的 object,會(huì)先被標(biāo)記成黑色再返回入录。
示意圖:
還有一種情況蛤奥,標(biāo)記過程中,堆上的 object 被賦值給了一個(gè)棧上指針纷跛,導(dǎo)致這個(gè) object 沒有被標(biāo)記到喻括。因?yàn)閷?duì)棧上指針進(jìn)行寫入,寫屏障是檢測(cè)不到的贫奠。下圖展示了整個(gè)流程(其中 L 是棧上指針):
為了解決這個(gè)問題唬血,標(biāo)記的最后階段,還會(huì)回頭重新掃描一下所有的椈秸福空間拷恨,確保沒有遺漏。而這個(gè)過程就需要啟動(dòng) STW 了谢肾,否則并發(fā)場(chǎng)景會(huì)使上述場(chǎng)景反復(fù)重現(xiàn)腕侄。
整個(gè) GC 流程如下圖所示:
解釋下:
- 正常情況下,寫操作就是正常的賦值芦疏。
- GC 開始冕杠,開啟寫屏障等準(zhǔn)備工作。開啟寫屏障等準(zhǔn)備工作需要短暫的 STW酸茴。
- Stack scan 階段分预,從全局空間和 goroutine 棧空間上收集變量薪捍。
- Mark 階段笼痹,執(zhí)行上述的三色標(biāo)記法,直到?jīng)]有灰色對(duì)象酪穿。
- Mark termination 階段凳干,開啟 STW,回頭重新掃描 root 區(qū)域新變量被济,對(duì)他們進(jìn)行標(biāo)記救赐。
- Sweep 階段,關(guān)閉 STW 和 寫屏障只磷,對(duì)白色對(duì)象進(jìn)行清除净响。
Hibrid Write Barrier
三色標(biāo)記方式少欺,需要在最后重新掃描一下所有全局變量和 goroutine 棧空間馋贤,如果系統(tǒng)的 goroutine 很多赞别,這個(gè)階段耗時(shí)也會(huì)比較長,甚至?xí)L達(dá) 100ms配乓。畢竟 Goroutine 很輕量仿滔,大型系統(tǒng)中,上百萬的 Goroutine 也是常有的事兒犹芹。
上面說對(duì)棧上指針進(jìn)行寫入崎页,寫屏障是檢測(cè)不到,實(shí)際上并不是做不到腰埂,而是代價(jià)非常高飒焦,Go 的寫屏障故意沒去管它,而是采取了再次掃描的方案屿笼。
Go 在 1.8 版本引入了混合寫屏障牺荠,其會(huì)在賦值前,對(duì)舊數(shù)據(jù)置灰驴一,再視情況對(duì)新值進(jìn)行置灰休雌。大致如下圖所示:
這樣就不需要在最后回頭重新掃描所有 Goroutine 的棧空間了肝断,這使得整個(gè) GC 過程 STW 幾乎可以忽略不計(jì)了杈曲。
寫屏障的偽代碼如下(看不懂可忽略):
writePointer(slot, ptr): // 1.8 之前
shade(ptr)
*slot = ptr
writePointer(slot, ptr): // 1.8 之后
shade(*slot)
if current stack is grey:
shade(ptr)
*slot = ptr
混合寫屏障會(huì)有一點(diǎn)小小的代價(jià),就是上例中如果 C
沒有賦值給 L
胸懈,用戶執(zhí)行 B.next = nil
后担扑,C
的的確確變成了垃圾,而我們卻把置灰了趣钱,使得 C
只能等到下一輪 GC 才能被回收了涌献。
GC 過程創(chuàng)建的新對(duì)象直接標(biāo)記成黑色也會(huì)帶來這個(gè)問題,即使新 object 在掃描結(jié)束前變成了垃圾羔挡,這次 GC 也不會(huì)回收它,只能等下輪间唉。
何時(shí)觸發(fā) GC
一般是當(dāng) Heap 上的內(nèi)存達(dá)到一定數(shù)值后绞灼,會(huì)觸發(fā)一次 GC,這個(gè)數(shù)值我們可以通過環(huán)境變量 GOGC
或者 debug.SetGCPercent()
設(shè)置呈野,默認(rèn)是 100
低矮,表示當(dāng)內(nèi)存增長 100%
執(zhí)行一次 GC。如果當(dāng)前堆內(nèi)存使用了 10MB
被冒,那么等到它漲到 20MB
的時(shí)候就會(huì)觸發(fā) GC军掂。
再就是每隔 2 分鐘轮蜕,如果期間內(nèi)沒有觸發(fā) GC,也會(huì)強(qiáng)制觸發(fā)一次蝗锥。
最后就是用戶手動(dòng)觸發(fā)了跃洛,也就是調(diào)用 runtime.GC()
強(qiáng)制觸發(fā)一次。
其他優(yōu)化
掃描過程最多使用 25% 的 CPU 進(jìn)行標(biāo)記终议,這是為了盡可能降低 GC 過程對(duì)用戶的影響汇竭。而如果 GC 未完成,下一輪 GC 又觸發(fā)了穴张,系統(tǒng)會(huì)等待上一輪 GC 結(jié)束细燎。
對(duì)于 tiny 對(duì)象,標(biāo)記階段是直接標(biāo)記成黑色了皂甘,沒有灰色階段玻驻。因?yàn)?tiny 對(duì)象是不存放引用類型數(shù)據(jù)(指針)的,這個(gè)在 Go 語言內(nèi)存管理(二):Go 內(nèi)存管理 提到過偿枕,沒必要標(biāo)記成灰色再檢查一遍璧瞬。
結(jié)論
Go 的 GC 會(huì)不斷演進(jìn),盡管現(xiàn)在1.12
版本跟幾年前的版本已經(jīng)有了很大的提升了益老,但 GC 仍然是大家吐槽的焦點(diǎn)之一彪蓬。作為用戶能做的就是盡可能在代碼上避開 GC(如果有這個(gè)必要),比如盡量少用存在多級(jí)引用的數(shù)據(jù)結(jié)構(gòu)捺萌,比如 chan map[string][]*string
這種糟糕的數(shù)據(jù)結(jié)構(gòu)档冬。引用層級(jí)越多,GC 的成本也就越高桃纯。
估計(jì) Go 后續(xù)也會(huì)引入分代機(jī)制的酷誓,個(gè)人認(rèn)為這會(huì)很大程度提升 GC 效率。我在 Go 語言內(nèi)存管理(二):Go 內(nèi)存管理 提到過金字塔模型态坦,分代機(jī)制本質(zhì)上就是構(gòu)造金字塔結(jié)構(gòu)盐数,將 GC 工作分成幾級(jí)來完成。像 JVM 那樣將內(nèi)存分成新生代伞梯,老生代玫氢,永生代,不同生代投入不同的計(jì)算資源谜诫。
現(xiàn)在這樣每次都要全局掃描所有對(duì)象漾峡,進(jìn)行標(biāo)記回收,效率確實(shí)不怎么高喻旷。
我曾在一些項(xiàng)目中使用全局對(duì)象池的方案生逸,企圖降低內(nèi)存分配回收壓力,但效果一般,雖然 mallocgc
和 gcSweep
不怎么吃 CPU 了槽袄,但 gcMark
壓力變大烙无,成了無解的存在。如果可以將對(duì)象池放到老生代中遍尺,不讓 GC 頻繁的對(duì)其掃描截酷,相信性能會(huì)有較大的提升。
還有種方法是直接申請(qǐng)一塊大內(nèi)存空間(大于32K)狮鸭,這樣對(duì)于 GC 來說它就是一個(gè) largespan
合搅;但對(duì)這個(gè)大空間的分配使用就需要我們自己寫代碼管理了,我們將會(huì)遇到和操作系統(tǒng)內(nèi)存管理類似的問題歧蕉,比如內(nèi)存碎片灾部,指針問題,并發(fā)問題等等惯退,非常麻煩赌髓,寫得不好性能反而會(huì)更差。好在已有成熟的開源項(xiàng)目 freecache和 bigcache 可直接使用催跪。