Golang 是如何GC的?

一、什么是 GC ?

GC:垃圾回收(Garbage Collection)是一種自動(dòng)管理內(nèi)存的機(jī)制。傳統(tǒng)的編程語(yǔ)言(C/C++)中,釋放無(wú)用變量?jī)?nèi)存空間是程序員手動(dòng)釋放,存在內(nèi)存泄漏或者釋放不該釋放內(nèi)存等問(wèn)題;為了解決這個(gè)問(wèn)題,后續(xù)的語(yǔ)言(oc/swift/java/python/php/golang 等)都引入了語(yǔ)言層面的自動(dòng)內(nèi)存管理,語(yǔ)言使用者無(wú)需對(duì)內(nèi)存進(jìn)行手動(dòng)釋放,內(nèi)存釋放由虛擬機(jī)(virtual machine)或者運(yùn)行時(shí)(runtime)來(lái)對(duì)不再使用的內(nèi)存資源進(jìn)行自動(dòng)回收。

Golang GC 的發(fā)展史

  • go1.1,串行三色清掃。
  • go1.3,提高了垃圾回收的精確度。
  • go1.4,之前版本的runtime大部分是使用C寫(xiě)的,這個(gè)版本大量使用Go進(jìn)行了重寫(xiě),讓GC有了掃描stack的能力,進(jìn)一步提高了垃圾回收的精確度。
  • go1.5,目標(biāo)是降低GC延遲,采用了并發(fā)標(biāo)記并發(fā)清除,三色標(biāo)記,write barrier,以及實(shí)現(xiàn)了更好的回收器調(diào)度,設(shè)計(jì)文檔1文檔2,以及2015 版的Go talk。
  • go1.6,小優(yōu)化,當(dāng)程序使用大量?jī)?nèi)存時(shí),GC暫停時(shí)間有所降低。
  • go1.7,小優(yōu)化,當(dāng)程序有大量空閑goroutine,stack大小波動(dòng)比較大時(shí),GC暫停時(shí)間有顯著降低。
  • go1.8,write barrier切換到hybrid write barrier,以消除STW中的re-scan,把STW的最差情況降低到50us,設(shè)計(jì)文檔。
  • go1.9,提升指標(biāo)比較多,(1)過(guò)去 runtime.GC, debug.SetGCPercent, 和 debug.FreeOSMemory都不能觸發(fā)并發(fā)GC,他們觸發(fā)的GC都是阻塞的,go1.9可以了,變成了在垃圾回收之前只阻塞調(diào)用GC的goroutine。(2)debug.SetGCPercent只在有必要的情況下才會(huì)觸發(fā)GC。
  • go.1.10,小優(yōu)化,加速了GC,程序應(yīng)當(dāng)運(yùn)行更快一點(diǎn)點(diǎn)。
  • go1.12,顯著提高了堆內(nèi)存存在大碎片情況下的sweeping性能,能夠降低GC后立即分配內(nèi)存的延遲。
  • go1.13,著手解決向操作系統(tǒng)歸還內(nèi)存的,提出了新的 Scavenger
  • go1.14,替代了僅存活了一個(gè)版本的 Scavenger,全新的頁(yè)分配器,優(yōu)化分配內(nèi)存過(guò)程的速率與現(xiàn)有的擴(kuò)展性問(wèn)題,并引入了異步搶占,解決了由于密集循環(huán)導(dǎo)致的 STW 時(shí)間過(guò)長(zhǎng)的問(wèn)題
  • go1.15,刪除了一些GC元數(shù)據(jù)和一些無(wú)用的類型元數(shù)據(jù),Go 1.15編譯出的二進(jìn)制文件size會(huì)減少5%左右。
  • ......

主要版本變化:

go-gc.png

1.5 版本以及以后版本的GC 主要分為四個(gè)階段,其中標(biāo)記和清理都是并發(fā)執(zhí)行的,但是標(biāo)記階段的前后需要使用STW來(lái)做GC的準(zhǔn)備工作和棧的rescan(這也是1.8的優(yōu)化點(diǎn))。

1.8 版本引入混合屏障,最小化第一次STW,寫(xiě)入屏障和刪除屏障各有優(yōu)缺點(diǎn),Dijkstra寫(xiě)入寫(xiě)屏障在標(biāo)記開(kāi)始時(shí)無(wú)需STW,可直接開(kāi)始,并發(fā)進(jìn)行,但結(jié)束時(shí)需要STW來(lái)重新掃描棧,標(biāo)記棧上引用的白色對(duì)象的存活;Yuasa的刪除寫(xiě)屏障則需要在GC開(kāi)始時(shí)STW掃描堆棧來(lái)記錄初始快照,這個(gè)過(guò)程會(huì)保護(hù)開(kāi)始時(shí)刻的所有存活對(duì)象,但結(jié)束時(shí)無(wú)需STW。Go1.8版本引入的混合寫(xiě)屏障結(jié)合了Yuasa的刪除寫(xiě)屏障和Dijkstra的寫(xiě)入寫(xiě)屏障,結(jié)合了兩者的優(yōu)點(diǎn)。

二、常見(jiàn)的 GC 算法

1. 引用計(jì)數(shù)法

根據(jù)對(duì)象自身的引用計(jì)數(shù)來(lái)回收,當(dāng)引用計(jì)數(shù)歸零時(shí)進(jìn)行回收,但是計(jì)數(shù)頻繁更新會(huì)帶來(lái)更多開(kāi)銷,且無(wú)法解決循環(huán)引用的問(wèn)題。
代表:Objective-C、Swift

  • 優(yōu)點(diǎn):簡(jiǎn)單直接,回收速度快
  • 缺點(diǎn):需要額外的空間存放計(jì)數(shù),無(wú)法處理循環(huán)引用的情況;

2. 標(biāo)記清除法

標(biāo)記出所有不需要回收的對(duì)象,在標(biāo)記完成后統(tǒng)一回收掉所有未被標(biāo)記的對(duì)象。

標(biāo)記清除法.png
  • 優(yōu)點(diǎn):簡(jiǎn)單直接,速度快,適合可回收對(duì)象不多的場(chǎng)景
  • 缺點(diǎn):會(huì)造成不連續(xù)的內(nèi)存空間(內(nèi)存碎片),導(dǎo)致有大的對(duì)象創(chuàng)建的時(shí)候,明明內(nèi)存中總內(nèi)存是夠的,但是空間不是連續(xù)的造成對(duì)象無(wú)法分配;

3. 復(fù)制法

復(fù)制法將內(nèi)存分為大小相同的兩塊,每次使用其中的一塊,當(dāng)這一塊的內(nèi)存使用完后,將還存活的對(duì)象復(fù)制到另一塊去,然后再把使用的空間一次清理掉。

復(fù)制法.png
  • 優(yōu)點(diǎn):解決了內(nèi)存碎片的問(wèn)題,每次清除針對(duì)的都是整塊內(nèi)存,但是因?yàn)橐苿?dòng)對(duì)象需要耗費(fèi)時(shí)間,效率低于標(biāo)記清除法;
  • 缺點(diǎn):有部分內(nèi)存總是利用不到,資源浪費(fèi),移動(dòng)存活對(duì)象比較耗時(shí),并且如果存活對(duì)象較多的時(shí)候,需要擔(dān)保機(jī)制確保復(fù)制區(qū)有足夠的空間可完成復(fù)制;

4. 標(biāo)記整理

標(biāo)記過(guò)程同標(biāo)記清除法,結(jié)束后將存活對(duì)象壓縮至一端,然后清除邊界外的內(nèi)容。

標(biāo)記整理.png
  • 優(yōu)點(diǎn):解決了內(nèi)存碎片的問(wèn)題,也不像標(biāo)記復(fù)制法那樣需要擔(dān)保機(jī)制,存活對(duì)象較多的場(chǎng)景也使適用;
  • 缺點(diǎn):性能低,因?yàn)樵谝苿?dòng)對(duì)象的時(shí)候不僅需要移動(dòng)對(duì)象還要維護(hù)對(duì)象的引用地址,可能需要對(duì)內(nèi)存經(jīng)過(guò)幾次掃描才能完成;

5. 分代式

將對(duì)象根據(jù)存活時(shí)間的長(zhǎng)短進(jìn)行分類,存活時(shí)間小于某個(gè)值的為年輕代,存活時(shí)間大于某個(gè)值的為老年代,永遠(yuǎn)不會(huì)參與回收的對(duì)象為永久代。并根據(jù)分代假設(shè)(如果一個(gè)對(duì)象存活時(shí)間不長(zhǎng)則傾向于被回收,如果一個(gè)對(duì)象已經(jīng)存活很長(zhǎng)時(shí)間則傾向于存活更長(zhǎng)時(shí)間)對(duì)對(duì)象進(jìn)行回收。

三、Golang 選擇的 GC 算法

Golang GC 算法使用的是無(wú)無(wú)分代(對(duì)象沒(méi)有代際之分)、不整理(回收過(guò)程中不對(duì)對(duì)象進(jìn)行移動(dòng)與整理)、并發(fā)(與用戶代碼并發(fā)執(zhí)行)的三色標(biāo)記清掃算法。
原因在于:

  1. 對(duì)象整理的優(yōu)勢(shì)是解決內(nèi)存碎片問(wèn)題以及“允許”使用順序內(nèi)存分配器。但 Go 運(yùn)行時(shí)的分配算法基于 tcmalloc,基本上沒(méi)有碎片問(wèn)題。 并且順序內(nèi)存分配器在多線程的場(chǎng)景下并不適用。Go 使用的是基于 tcmalloc 的現(xiàn)代內(nèi)存分配算法,對(duì)對(duì)象進(jìn)行整理不會(huì)帶來(lái)實(shí)質(zhì)性的性能提升。
  2. 分代 GC 依賴分代假設(shè),即 GC 將主要的回收目標(biāo)放在新創(chuàng)建的對(duì)象上(存活時(shí)間短,更傾向于被回收),而非頻繁檢查所有對(duì)象。但 Go 的編譯器會(huì)通過(guò)逃逸分析將大部分新生對(duì)象存儲(chǔ)在棧上(棧直接被回收),只有那些需要長(zhǎng)期存在的對(duì)象才會(huì)被分配到需要進(jìn)行垃圾回收的堆中。也就是說(shuō),分代 GC 回收的那些存活時(shí)間短的對(duì)象在 Go 中是直接被分配到棧上,當(dāng) goroutine 死亡后棧也會(huì)被直接回收,不需要 GC 的參與,進(jìn)而分代假設(shè)并沒(méi)有帶來(lái)直接優(yōu)勢(shì)。并且 Go 的垃圾回收器與用戶代碼并發(fā)執(zhí)行,使得 STW 的時(shí)間與對(duì)象的代際、對(duì)象的 size 沒(méi)有關(guān)系。Go 團(tuán)隊(duì)更關(guān)注于如何更好地讓 GC 與用戶代碼并發(fā)執(zhí)行(使用適當(dāng)?shù)?CPU 來(lái)執(zhí)行垃圾回收),而非減少停頓時(shí)間這一單一目標(biāo)上。

1. 三色標(biāo)記法的原理

三色標(biāo)記法將對(duì)象分為三類,并用不同的顏色相稱:

  1. 白色對(duì)象(可能死亡):未被回收器訪問(wèn)到的對(duì)象。在回收開(kāi)始階段,所有對(duì)象均為白色,當(dāng)回收結(jié)束后,白色對(duì)象均不可達(dá)。
  2. 灰色對(duì)象(波面):已被回收器訪問(wèn)到的對(duì)象,但回收器需要對(duì)其中的一個(gè)或多個(gè)指針進(jìn)行掃描,因?yàn)樗麄兛赡苓€指向白色對(duì)象。
  3. 黑色對(duì)象(確定存活):已被回收器訪問(wèn)到的對(duì)象,其中所有字段都已被掃描,黑色對(duì)象中任何一個(gè)指針都不可能直接指向白色對(duì)象。

標(biāo)記過(guò)程如下:

  • 第一步:起初所有的對(duì)象都是白色的;
  • 第二步:從根對(duì)象出發(fā)掃描所有可達(dá)對(duì)象,標(biāo)記為灰色,放入待處理隊(duì)列;
  • 第三步:從待處理隊(duì)列中取出灰色對(duì)象,將其引用的對(duì)象標(biāo)記為灰色并放入待處理隊(duì)列中,自身標(biāo)記為黑色;
  • 重復(fù)第三步,直到待處理隊(duì)列為空,此時(shí)白色對(duì)象即為不可達(dá)的“垃圾”,回收白色對(duì)象;
go-gc.gif

根對(duì)象在垃圾回收的術(shù)語(yǔ)中又叫做根集合,它是垃圾回收器在標(biāo)記過(guò)程時(shí)最先檢查的對(duì)象,包括:

  1. 全局變量:程序在編譯期就能確定的那些存在于程序整個(gè)生命周期的變量。
  2. 執(zhí)行棧:每個(gè) goroutine 都包含自己的執(zhí)行棧,這些執(zhí)行棧上包含棧上的變量及指向分配的堆內(nèi)存區(qū)塊的指針。
  3. 寄存器:寄存器的值可能表示一個(gè)指針,參與計(jì)算的這些指針可能指向某些賦值器分配的堆內(nèi)存區(qū)塊。

2. 屏障機(jī)制

2.1 STW

STW 是Start/Stop The World的縮寫(xiě)。通常意義上指的是從 Stop The WorldStart The World 這一段時(shí)間間隔。垃圾回收過(guò)程中為了保證準(zhǔn)確性、防止無(wú)止境的內(nèi)存增長(zhǎng)等問(wèn)題而不可避免的需要停止賦值器進(jìn)一步操作對(duì)象圖以完成垃圾回收。STW時(shí)間越長(zhǎng),對(duì)用戶代碼造成的影響越大。

2.2 No STW 存在的問(wèn)題

假設(shè)下面的場(chǎng)景,已經(jīng)被標(biāo)記為灰色的對(duì)象2,未被標(biāo)記的對(duì)象3被對(duì)象2用指針p引用;此時(shí)已經(jīng)被標(biāo)記為黑色的對(duì)象4創(chuàng)建指針q 指向未被標(biāo)記的對(duì)象3,同時(shí)對(duì)象2將指針p移除;對(duì)象4已經(jīng)被標(biāo)記為黑色,對(duì)象3未被引用,對(duì)象2刪除與對(duì)象3的引用,導(dǎo)致最后對(duì)象3被誤清除;

三色標(biāo)記法,不啟動(dòng)STW - 圖1
三色標(biāo)記法,不啟動(dòng)STW - 圖2
三色標(biāo)記法,不啟動(dòng)STW - 圖3
三色標(biāo)記法,不啟動(dòng)STW - 圖4
三色標(biāo)記法,不啟動(dòng)STW - 圖5
  • 垃圾回收的原則是不應(yīng)出現(xiàn)對(duì)象的丟失,也不應(yīng)錯(cuò)誤的回收還不需要回收的對(duì)象。如果同時(shí)滿足下面兩個(gè)條件會(huì)破壞回收器的正確性:

    • 條件 1: 賦值器修改對(duì)象圖,導(dǎo)致某一黑色對(duì)象引用白色對(duì)象;(通俗的說(shuō)就是A突然持有了B的指針,而B(niǎo)在并發(fā)標(biāo)記的過(guò)程中已經(jīng)被判定為白色對(duì)象要被清理掉的)
    • 條件 2: 從灰色對(duì)象出發(fā),到達(dá)白色對(duì)象且未經(jīng)訪問(wèn)過(guò)的路徑被賦值器破壞;(通俗的說(shuō)就是A持有B的指針,這個(gè)持有關(guān)系被釋放)
  • 只要能夠避免其中任何一個(gè)條件,則不會(huì)出現(xiàn)對(duì)象丟失的情況,因?yàn)椋?/p>

    • 如果“條件 1”被避免,則所有白色對(duì)象均被灰色對(duì)象引用,沒(méi)有白色對(duì)象會(huì)被遺漏;
    • 如果“條件 2”被避免,即便白色對(duì)象的指針被寫(xiě)入到黑色對(duì)象中,但從灰色對(duì)象出發(fā),總存在一條沒(méi)有訪問(wèn)過(guò)的路徑,從而找到到達(dá)白色對(duì)象的路徑,白色對(duì)象最終不會(huì)被遺漏。

可能的解決方法: 整個(gè)過(guò)程STW,浪費(fèi)資源,且對(duì)用戶程序影響較大,由此引入了屏障機(jī)制;

2.3 屏障機(jī)制

把回收器視為對(duì)象,把賦值器視為影響回收器這一對(duì)象的實(shí)際行為(即影響 GC 周期的長(zhǎng)短),從而引入賦值器的顏色:

  • 黑色賦值器:已經(jīng)由回收器掃描過(guò),不會(huì)再次對(duì)其進(jìn)行掃描。
  • 灰色賦值器:尚未被回收器掃描過(guò)或盡管已經(jīng)掃描過(guò),但仍需要重新掃描。

2.3.1 插入屏障(Dijkstra)- 灰色賦值器

寫(xiě)入前,對(duì)指針?biāo)赶虻膶?duì)象進(jìn)行著色

// 灰色賦值器 Dijkstra 插入屏障
func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(ptr) //先將新下游對(duì)象 ptr 標(biāo)記為灰色
    *slot = ptr
}

//說(shuō)明:
添加下游對(duì)象(當(dāng)前下游對(duì)象slot, 新下游對(duì)象ptr) {   
  //step 1
  標(biāo)記灰色(新下游對(duì)象ptr)   
  
  //step 2
  當(dāng)前下游對(duì)象slot = 新下游對(duì)象ptr                    
}

//場(chǎng)景:
A.添加下游對(duì)象(nil, B)   //A 之前沒(méi)有下游, 新添加一個(gè)下游對(duì)象B, B被標(biāo)記為灰色
A.添加下游對(duì)象(C, B)     //A 將下游對(duì)象C 更換為B,  B被標(biāo)記為灰色

避免條件1( 賦值器修改對(duì)象圖,導(dǎo)致某一黑色對(duì)象引用白色對(duì)象;)因?yàn)樵趯?duì)象A 引用對(duì)象B 的時(shí)候,B 對(duì)象被標(biāo)記為灰色

Dijkstra 插入屏障的好處在于可以立刻開(kāi)始并發(fā)標(biāo)記。但存在兩個(gè)缺點(diǎn):

  • 由于 Dijkstra 插入屏障的“保守”,在一次回收過(guò)程中可能會(huì)殘留一部分對(duì)象沒(méi)有回收成功,只有在下一個(gè)回收過(guò)程中才會(huì)被回收;
  • 在標(biāo)記階段中,每次進(jìn)行指針賦值操作時(shí),都需要引入寫(xiě)屏障,這無(wú)疑會(huì)增加大量性能開(kāi)銷;為了避免造成性能問(wèn)題,Go 團(tuán)隊(duì)在最終實(shí)現(xiàn)時(shí),沒(méi)有為所有棧上的指針寫(xiě)操作,啟用寫(xiě)屏障,而是當(dāng)發(fā)生棧上的寫(xiě)操作時(shí),將棧標(biāo)記為灰色,但此舉產(chǎn)生了灰色賦值器,將會(huì)需要標(biāo)記終止階段 STW 時(shí)對(duì)這些棧進(jìn)行重新掃描。
三色標(biāo)記并發(fā),插入屏障 - 圖1
三色標(biāo)記并發(fā),插入屏障 - 圖2
三色標(biāo)記并發(fā),插入屏障 - 圖3
三色標(biāo)記并發(fā),插入屏障 - 圖4
三色標(biāo)記并發(fā),插入屏障 - 圖5
三色標(biāo)記并發(fā),插入屏障 - 圖6
三色標(biāo)記并發(fā),插入屏障 - 圖7
三色標(biāo)記并發(fā),插入屏障 - 圖8
三色標(biāo)記并發(fā),插入屏障 - 圖9
三色標(biāo)記并發(fā),插入屏障 - 圖10

特點(diǎn):在標(biāo)記開(kāi)始時(shí)無(wú)需STW,可直接開(kāi)始,并發(fā)進(jìn)行,但結(jié)束時(shí)需要STW來(lái)重新掃描棧

2.3.2 刪除屏障 (Yuasa)- 黑色賦值器

寫(xiě)入前,對(duì)指針?biāo)趯?duì)象進(jìn)行著色

// 黑色賦值器 Yuasa 屏障
func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(*slot) 先將*slot標(biāo)記為灰色
    *slot = ptr
}

//說(shuō)明:
添加下游對(duì)象(當(dāng)前下游對(duì)象slot, 新下游對(duì)象ptr) {
  //step 1
  if (當(dāng)前下游對(duì)象slot是灰色 || 當(dāng)前下游對(duì)象slot是白色) {
          標(biāo)記灰色(當(dāng)前下游對(duì)象slot)     //slot為被刪除對(duì)象, 標(biāo)記為灰色
  }  
  //step 2
  當(dāng)前下游對(duì)象slot = 新下游對(duì)象ptr
}

//場(chǎng)景
A.添加下游對(duì)象(B, nil)   //A對(duì)象,刪除B對(duì)象的引用。B被A刪除,被標(biāo)記為灰(如果B之前為白)
A.添加下游對(duì)象(B, C)     //A對(duì)象,更換下游B變成C。B被A刪除,被標(biāo)記為灰(如果B之前為白)

避免條件2(從灰色對(duì)象出發(fā),到達(dá)白色對(duì)象的、未經(jīng)訪問(wèn)過(guò)的路徑被賦值器破壞),因?yàn)楸粍h除對(duì)象,如果自身是灰色或者白色,則被標(biāo)記為灰色。

GC三色標(biāo)記并發(fā):刪除屏障 - 圖1
GC三色標(biāo)記并發(fā):刪除屏障 - 圖2
GC三色標(biāo)記并發(fā):刪除屏障 - 圖3
GC三色標(biāo)記并發(fā):刪除屏障 - 圖4
GC三色標(biāo)記并發(fā):刪除屏障 - 圖5
GC三色標(biāo)記并發(fā):刪除屏障 - 圖6
GC三色標(biāo)記并發(fā):刪除屏障 - 圖7

特點(diǎn):標(biāo)記結(jié)束不需要STW,但是回收精度低,GC 開(kāi)始時(shí)STW 掃描堆棧記錄初始快照,保護(hù)開(kāi)始時(shí)刻的所有存活對(duì)象;且容易產(chǎn)生“冗余”掃描;

2.3.3 混合屏障

大大縮短了 STW 時(shí)間

GC 開(kāi)始將棧上的對(duì)象全部掃描并標(biāo)記為黑色;
GC 期間,任何在棧上創(chuàng)建的新對(duì)象,均為黑色;
被刪除的堆對(duì)象標(biāo)記為灰色;
被添加的堆對(duì)象標(biāo)記為灰色;

// 混合寫(xiě)屏障
func HybridWritePointerSimple(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(*slot)
    shade(ptr)
    *slot = ptr
}
混合屏障 - 圖1
混合屏障 - 圖2

場(chǎng)景一:對(duì)象被一個(gè)堆對(duì)象刪除引用,成為棧對(duì)象的下游

由于屏障的作用,對(duì)象7不會(huì)被誤刪除;

混合屏障 - 場(chǎng)景1 - 圖1
混合屏障 - 場(chǎng)景1 - 圖2

場(chǎng)景二:對(duì)象被一個(gè)棧對(duì)象刪除引用,成為棧對(duì)象的下游

混合屏障 - 場(chǎng)景2 - 圖1
混合屏障 - 場(chǎng)景2 - 圖2
混合屏障 - 場(chǎng)景2 - 圖3

場(chǎng)景三:對(duì)象被一個(gè)堆對(duì)象刪除引用,成為堆對(duì)象的下游

混合屏障 - 場(chǎng)景3 - 圖1
混合屏障 - 場(chǎng)景3 - 圖2
混合屏障 - 場(chǎng)景3 - 圖3

場(chǎng)景四:對(duì)象被一個(gè)棧對(duì)象刪除引用,成為另一個(gè)堆對(duì)象的下游

混合屏障 - 場(chǎng)景4 - 圖1
混合屏障 - 場(chǎng)景4 - 圖2
混合屏障 - 場(chǎng)景4 - 圖3

Golang 中的混合屏障結(jié)合了刪除寫(xiě)屏障和插入寫(xiě)屏障的優(yōu)點(diǎn),只需要在開(kāi)始時(shí)并發(fā)掃描各goroutine的棧,使其變黑并一直保持,標(biāo)記結(jié)束后,因?yàn)闂?臻g在掃描后始終是黑色的,無(wú)需進(jìn)行re-scan,減少了STW 的時(shí)間。

3. Go GC 流程

3.1 標(biāo)記清理

Marking setup

為了打開(kāi)寫(xiě)屏障,必須停止每個(gè)goroutine,讓垃圾收集器觀察并等待每個(gè)goroutine進(jìn)行函數(shù)調(diào)用, 等待函數(shù)調(diào)用是為了保證goroutine停止時(shí)處于安全點(diǎn)。

set up
// 如果goroutine4 處于如下循環(huán)中,運(yùn)行時(shí)間取決于slice numbers的大小
func add(numbers []int) int {
    var v int
    for _, n := range numbers {
             v += n
     }
     return v
}

下面的代碼中,由于for{} 循環(huán)所在的goroutine 永遠(yuǎn)不會(huì)中斷,導(dǎo)致始終無(wú)法進(jìn)入STW階段,資源浪費(fèi);Go 1.14 之后,此類goroutine 能被異步搶占,使得進(jìn)入STW的時(shí)間不會(huì)超過(guò)搶占信號(hào)觸發(fā)的周期,程序也不會(huì)因?yàn)閮H僅等待一個(gè)goroutine的停止而停頓在進(jìn)入STW之前的操作上。

func main() {
    go func() {
        for {
        }
    }()
    time.Sleep(time.Milliecond)
    runtime.GC()
    println("done")
}
golang 不同版本GC STW時(shí)間關(guān)系

Marking

一旦寫(xiě)屏障打開(kāi),垃圾收集器就開(kāi)始標(biāo)記階段,垃圾收集器所做的第一件事是占用25%CPU。

標(biāo)記階段需要標(biāo)記在堆內(nèi)存中仍然在使用中的值。首先檢查所有現(xiàn)goroutine的堆棧,以找到堆內(nèi)存的根指針。然后收集器必須從那些根指針遍歷堆內(nèi)存圖,標(biāo)記可以回收的內(nèi)存。

當(dāng)存在新的內(nèi)存分配時(shí),會(huì)暫停分配內(nèi)存過(guò)快的那些 goroutine,并將其轉(zhuǎn)去執(zhí)行一些輔助標(biāo)記(Mark Assist)的工作,從而達(dá)到放緩繼續(xù)分配、輔助 GC 的標(biāo)記工作的目的。

marking-1
marking-2

Mark 終止

關(guān)閉寫(xiě)屏障,執(zhí)行各種清理任務(wù)(STW - optional

Mark 終止-1
Mark 終止-2

Sweep(清理)

清理階段用于回收標(biāo)記階段中標(biāo)記出來(lái)的可回收內(nèi)存。當(dāng)應(yīng)用程序goroutine嘗試在堆內(nèi)存中分配新內(nèi)存時(shí),會(huì)觸發(fā)該操作,清理導(dǎo)致的延遲和吞吐量降低被分散到每次內(nèi)存分配時(shí)。

階段 說(shuō)明 賦值器狀態(tài)
SweepTermination 掃描終止階段,為下一階段的并發(fā)標(biāo)記做準(zhǔn)備工作,啟動(dòng)寫(xiě)屏障 STW
Mark 掃描標(biāo)記階段,與賦值器并發(fā)執(zhí)行,寫(xiě)屏障開(kāi)啟 并發(fā)
MarkTermination 標(biāo)記終止階段,保證一個(gè)周期內(nèi)標(biāo)記任務(wù)完成,停止寫(xiě)屏障 STW
GCoff 內(nèi)存清掃階段,將需要回收的內(nèi)存歸還到堆中,寫(xiě)屏障關(guān)閉 并發(fā)
GCoff 內(nèi)存歸還階段,將過(guò)多的內(nèi)存歸還給操作系統(tǒng),寫(xiě)屏障關(guān)閉 并發(fā)

問(wèn):清除階段出現(xiàn)新對(duì)象:

清除階段是掃描整個(gè)堆內(nèi)存,可以知道當(dāng)前清除到什么位置,創(chuàng)建的新對(duì)象判定下,如果新對(duì)象的指針位置已經(jīng)被掃描過(guò)了,那么就不用作任何操作,不會(huì)被誤清除,如果在當(dāng)前掃描的位置的后面,把該對(duì)象的顏色標(biāo)記為黑色,這樣就不會(huì)被誤清除了

問(wèn):什么時(shí)候進(jìn)行清理?

主動(dòng)觸發(fā)(runtime.GC()) 被動(dòng)觸發(fā) (GC百分比、定時(shí))

3.2 GC 百分比

運(yùn)行時(shí)中有GC 百分比的配置選項(xiàng),默認(rèn)情況下為100。此值表示在下一次垃圾收集必須啟動(dòng)之前可以分配多少新內(nèi)存的比率。將GC百分比設(shè)置為100意味著:基于在垃圾收集完成后標(biāo)記為活動(dòng)的堆內(nèi)存量,下次垃圾收集前,堆內(nèi)存使用可以增加100%。

3.3 GC 過(guò)程演示

演示一個(gè)GC過(guò)程,并輸出相關(guān)信息,使用GODEBUG變量生成GC trace

func gcfinished() *int {
   p := 1
   runtime.SetFinalizer(&p, func(_ *int) {
      println("gc finished")
      atomic.StoreUint64(&stop, 1) // 通知停止分配
   })
   return &p
}
func allocate() {
   _ = make([]byte, int((1<<20)*0.25))
}
func main() {
   f, _ := os.Create("trace.out")
   defer f.Close()
   trace.Start(f)
   defer trace.Stop()
   gcfinished()
   // 當(dāng)完成 GC 時(shí)停止分配
  for n := 1; n < 50; n++ {
      println("#allocate: ", n)
      allocate()
   }
   println("terminate")
}
gc演示
gc 1      : 第一個(gè)GC周期
@0.001s   : 從程序開(kāi)始運(yùn)行到第一次GC時(shí)間為0.001 秒
6%        : 此次GC過(guò)程中CPU 占用率

wall clock
 0.037+0.18+0.045 ms clock
0.037 ms  : STW,Marking Start, 開(kāi)啟寫(xiě)屏障
0.18 ms   : Marking階段
0.945 ms  : STW,Marking終止,關(guān)閉寫(xiě)屏障

CPU time
0.60+0.077/0.056/0.12+0.72 ms cpu
0.60 ms   : STW,Marking Start
0.077 ms  : 輔助標(biāo)記時(shí)間
0.056 ms  : 并發(fā)標(biāo)記時(shí)間
0.12 ms   : GC 空閑時(shí)間
0.72 ms   : Mark 終止時(shí)間

4->4->0 MB, 5 MB goal
4 MB      :標(biāo)記開(kāi)始時(shí),堆大小實(shí)際值
4 MB      :標(biāo)記結(jié)束時(shí),堆大小實(shí)際值
0 MB      :標(biāo)記結(jié)束時(shí),標(biāo)記為存活對(duì)象大小
5 MB      :標(biāo)記結(jié)束時(shí),堆大小預(yù)測(cè)值

16 P
16P       :本次GC過(guò)程中使用的goroutine 數(shù)量

第一次堆增長(zhǎng)率 :h_t=+8.750000e-001
第一次的運(yùn)行過(guò)程中的實(shí)際堆增長(zhǎng)率為:h_a=+1.321777e+000
第一次實(shí)際的堆大小為:5193728
第一次目標(biāo)的堆大?。?439488
第一次的 CPU 實(shí)際使用率為:u_a=+2.702135e-001
第一次的 CPU 目標(biāo)使用率為:u_g=+3.000000e-001

四、關(guān)注指標(biāo)與調(diào)優(yōu)示例

4.1 關(guān)注指標(biāo)

Go 的 GC 被設(shè)計(jì)為成比例觸發(fā)、大部分工作與賦值器并發(fā)、不分代、無(wú)內(nèi)存移動(dòng)且會(huì)主動(dòng)向操作系統(tǒng)歸還申請(qǐng)的內(nèi)存。因此最主要關(guān)注的、能夠影響賦值器的性能指標(biāo)有:

  • CPU 利用率:回收算法會(huì)在多大程度上拖慢程序?有時(shí)候,這個(gè)是通過(guò)回收占用的 CPU 時(shí)間與其它 CPU 時(shí)間的百分比來(lái)描述的。
  • GC 停頓時(shí)間:回收器會(huì)造成多長(zhǎng)時(shí)間的停頓?目前的 GC 中需要考慮 STW 和 Mark Assist 兩個(gè)部分可能造成的停頓。
  • GC 停頓頻率:回收器造成的停頓頻率是怎樣的?目前的 GC 中需要考慮 STW 和 Mark Assist 兩個(gè)部分可能造成的停頓。
  • GC 可擴(kuò)展性:當(dāng)堆內(nèi)存變大時(shí),垃圾回收器的性能如何?但大部分的程序可能并不一定關(guān)心這個(gè)問(wèn)題。

4.2 調(diào)優(yōu)示例

4.2.1 合理化內(nèi)存分配的速度、提高賦值器的 CPU 利用率

goroutine 的執(zhí)行時(shí)間占其生命周期總時(shí)間非常短的一部分,但大部分時(shí)間都花費(fèi)在調(diào)度器的等待上了,說(shuō)明同時(shí)創(chuàng)建大量 goroutine 對(duì)調(diào)度器產(chǎn)生的壓力確實(shí)不小,我們不妨將這一產(chǎn)生速率減慢,一批一批地創(chuàng)建 goroutine。

func concat() {
   for n := 0; n < 100; n++ {
      for i := 0; i < 8; i++ {
         go func() {
            s := "Go GC"
            s += " " + "Hello"
            s += " " + "World"
            _ = s
         }()
      }
   }
}

//改進(jìn)
func concat() {
   wg := sync.WaitGroup{}
   for n := 0; n < 100; n++ {
      wg.Add(8)
      for i := 0; i < 8; i++ {
         go func() {
            s := "Go GC"
            s += " " + "Hello"
            s += " " + "World"
            _ = s
            wg.Done()
         }()
      }
      wg.Wait()
   }
}

4.2.2 降低并復(fù)用已經(jīng)申請(qǐng)的內(nèi)存

newBuf()產(chǎn)生的申請(qǐng)的內(nèi)存過(guò)多, sync.Pool 是內(nèi)存復(fù)用的一個(gè)最為顯著的例子

func newBuf() []byte {
   return make([]byte, 10<<20)
}
b := newBuf()

//改進(jìn)
var bufPool = sync.Pool{
    New: func() interface{} {
        return make([]byte, 10<<20)
    },
}
b := bufPool.Get().([]byte)

4.2.3 調(diào)整 GOGC

降低收集器的啟動(dòng)頻率(提高GC百分比)無(wú)法幫助垃圾收集器更快完成收集工作。降低頻率會(huì)導(dǎo)致垃圾收集器在收集期間完成更多的工作。 可以通過(guò)減少新分配對(duì)象數(shù)量來(lái)幫助垃圾收集器更快完成收集工作。

4.3 小結(jié)

控制內(nèi)存分配的速度,限制 goroutine 的數(shù)量,從而提高賦值器對(duì) CPU 的利用率。
減少并復(fù)用內(nèi)存,例如使用 sync.Pool 來(lái)復(fù)用需要頻繁創(chuàng)建臨時(shí)對(duì)象,例如提前分配足夠的內(nèi)存來(lái)降低多余的拷貝。
需要時(shí),增大 GOGC 的值,降低 GC 的運(yùn)行頻率。

4.4 CPU 內(nèi)存占用過(guò)高問(wèn)題排查

導(dǎo)出智能問(wèn)答的數(shù)據(jù):導(dǎo)出數(shù)據(jù)時(shí),為每個(gè)app id開(kāi)了一個(gè)協(xié)程(由各自的協(xié)程獲取數(shù)據(jù),數(shù)據(jù)獲取完成后,協(xié)程將這些數(shù)據(jù)生成sheet,然后flush進(jìn)Excel文件),等待每個(gè)協(xié)程都運(yùn)行完成后,生成最終的導(dǎo)出文件,上傳到tos。在此過(guò)程中,每個(gè)協(xié)程都申請(qǐng)了較大的堆資源用于數(shù)據(jù)存儲(chǔ)數(shù)據(jù)和生成sheet,從而導(dǎo)致內(nèi)存升高。

參考:

【1】Golang三色標(biāo)記、混合寫(xiě)屏障GC模式圖文全分析
【2】Golang 三色標(biāo)記
【3】The Journey of Go's Garbage Collector
【4】Garbage Collection In Go
【5】Go垃圾回收
【6】Go內(nèi)存分配和管理
【7】GC的認(rèn)識(shí)
【8】如何做Go性能分析
【9】Golang GC 字節(jié)分享

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容