[Golang三關-典藏版] Golang三色標記混合寫屏障GC模式全分析

劉丹冰Aceld發表於2022-05-23

本篇文章已收錄於《Golang修養之路》www.yuque.com/aceld/golang/zhzanb 第一篇第5章節

本章節含影片版:

影片連結地址:www.bilibili.com/video/BV1wz4y1y7K...)


垃圾回收(Garbage Collection,簡稱GC)是程式語言中提供的自動的記憶體管理機制,自動釋放不需要的記憶體物件,讓出儲存器資源。GC過程中無需程式設計師手動執行。GC機制在現代很多程式語言都支援,GC能力的效能與優劣也是不同語言之間對比度指標之一。

Golang在GC的演進過程中也經歷了很多次變革,Go V1.3之前的標記-清除(mark and sweep)演算法,Go V1.3之前的標記-清掃(mark and sweep)的缺點

  • Go V1.5的三色併發標記法
  • Go V1.5的三色標記為什麼需要STW
  • Go V1.5的三色標記為什麼需要屏障機制(“強-弱” 三色不變式、插入屏障、刪除屏障 )
  • Go V1.8混合寫屏障機制
  • Go V1.8混合寫屏障機制的全場景分析

一、Go V1.3之前的標記-清除(mark and sweep)演算法

接下來我們來看一下在Golang1.3之前的時候主要用的普通的標記-清除演算法,此演算法主要有兩個主要的步驟:

  • 標記(Mark phase)
  • 清除(Sweep phase)

1 標記清除演算法的具體步驟

第一步,暫停程式業務邏輯, 分類出可達和不可達的物件,然後做上標記。

圖中表示是程式與物件的可達關係,目前程式的可達物件有物件1-2-3,物件4-7等五個物件。

第二步, 開始標記,程式找出它所有可達的物件,並做上標記。如下圖所示:

所以物件1-2-3、物件4-7等五個物件被做上標記。

第三步, 標記完了之後,然後開始清除未標記的物件. 結果如下。

操作非常簡單,但是有一點需要額外注意:mark and sweep演算法在執行的時候,需要程式暫停!即 STW(stop the world),STW的過程中,CPU不執行使用者程式碼,全部用於垃圾回收,這個過程的影響很大,所以STW也是一些回收機制最大的難題和希望最佳化的點。所以在執行第三步的這段時間,程式會暫定停止任何工作,卡在那等待回收執行完畢。

第四步, 停止暫停,讓程式繼續跑。然後迴圈重複這個過程,直到process程式生命週期結束。

以上便是標記-清除(mark and sweep)回收的演算法。

2 標記-清除(mark and sweep)的缺點

標記清除演算法明瞭,過程鮮明乾脆,但是也有非常嚴重的問題。

  • STW,stop the world;讓程式暫停,程式出現卡頓 (重要問題)
  • 標記需要掃描整個heap;
  • 清除資料會產生heap碎片。

Go V1.3版本之前就是以上來實施的, 在執行GC的基本流程就是首先啟動STW暫停,然後執行標記,再執行資料回收,最後停止STW,如圖所示。

從上圖來看,全部的GC時間都是包裹在STW範圍之內的,這樣貌似程式暫停的時間過長,影響程式的執行效能。所以Go V1.3 做了簡單的最佳化,將STW的步驟提前, 減少STW暫停的時間範圍.如下所示


上圖主要是將STW的步驟提前了非同步,因為在Sweep清除的時候,可以不需要STW停止,因為這些物件已經是不可達物件了,不會出現回收寫衝突等問題。

但是無論怎麼最佳化,Go V1.3都面臨這個一個重要問題,就是mark-and-sweep 演算法會暫停整個程式

Go是如何面對並這個問題的呢?接下來G V1.5版本 就用三色併發標記法來最佳化這個問題.

三、Go V1.5的三色併發標記法

Golang中的垃圾回收主要應用三色標記法,GC過程和其他使用者goroutine可併發執行,但需要一定時間的STW(stop the world),所謂三色標記法實際上就是透過三個階段的標記來確定清楚的物件都有哪些?我們來看一下具體的過程。

第一步 , 每次新建立的物件,預設的顏色都是標記為“白色”,如圖所示。

上圖所示,我們的程式可抵達的記憶體物件關係如左圖所示,右邊的標記表,是用來記錄目前每個物件的標記顏色分類。這裡面需要注意的是,所謂“程式”,則是一些物件的跟節點集合。所以我們如果將“程式”展開,會得到類似如下的表現形式,如圖所示。

第二步, 每次GC回收開始, 會從根節點開始遍歷所有物件,把遍歷到的物件從白色集合放入“灰色”集合如圖所示。


這裡 要注意的是,本次遍歷是一次遍歷,非遞迴形式,是從程式抽次可抵達的物件遍歷一層,如上圖所示,當前可抵達的物件是物件1和物件4,那麼自然本輪遍歷結束,物件1和物件4就會被標記為灰色,灰色標記表就會多出這兩個物件。

第三步, 遍歷灰色集合,將灰色物件引用的物件從白色集合放入灰色集合,之後將此灰色物件放入黑色集合,如圖所示。


這一次遍歷是隻掃描灰色物件,將灰色物件的第一層遍歷可抵達的物件由白色變為灰色,如:物件2、物件7. 而之前的灰色物件1和物件4則會被標記為黑色,同時由灰色標記表移動到黑色標記表中。

第四步, 重複第三步, 直到灰色中無任何物件,如圖所示。

當我們全部的可達物件都遍歷完後,灰色標記表將不再存在灰色物件,目前全部記憶體的資料只有兩種顏色,黑色和白色。那麼黑色物件就是我們程式邏輯可達(需要的)物件,這些資料是目前支撐程式正常業務執行的,是合法的有用資料,不可刪除,白色的物件是全部不可達物件,目前程式邏輯並不依賴他們,那麼白色物件就是記憶體中目前的垃圾資料,需要被清除。

第五步: 回收所有的白色標記表的物件. 也就是回收垃圾,如圖所示。

以上我們將全部的白色物件進行刪除回收,剩下的就是全部依賴的黑色物件。

以上便是三色併發標記法,不難看出,我們上面已經清楚的體現三色的特性。但是這裡面可能會有很多併發流程均會被掃描,執行併發流程的記憶體可能相互依賴,為了在GC過程中保證資料的安全,我們在開始三色標記之前就會加上STW,在掃描確定黑白物件之後再放開STW。但是很明顯這樣的GC掃描的效能實在是太低了。

那麼Go是如何解決標記-清除(mark and sweep)演算法中的卡頓(stw,stop the world)問題的呢?

四、沒有STW的三色標記法

先拋磚引玉,我們加入如果沒有STW,那麼也就不會再存在效能上的問題,那麼接下來我們假設如果三色標記法不加入STW會發生什麼事情?
我們還是基於上述的三色併發標記法來說, 他是一定要依賴STW的. 因為如果不暫停程式, 程式的邏輯改變物件引用關係, 這種動作如果在標記階段做了修改,會影響標記結果的正確性,我們來看看一個場景,如果三色標記法, 標記過程不使用STW將會發生什麼事情?

我們把初始狀態設定為已經經歷了第一輪掃描,目前黑色的有物件1和物件4, 灰色的有物件2和物件7,其他的為白色物件,且物件2是透過指標p指向物件3的,如圖所示。

現在如何三色標記過程不啟動STW,那麼在GC掃描過程中,任意的物件均可能發生讀寫操作,如圖所示,在還沒有掃描到物件2的時候,已經標記為黑色的物件4,此時建立指標q,並且指向白色的物件3。

與此同時灰色的物件2將指標p移除,那麼白色的物件3實則就是被掛在了已經掃描完成的黑色的物件4下,如圖所示。


然後我們正常指向三色標記的演算法邏輯,將所有灰色的物件標記為黑色,那麼物件2和物件7就被標記成了黑色,如圖所示。

那麼就執行了三色標記的最後一步,將所有白色物件當做垃圾進行回收,如圖所示。

但是最後我們才發現,本來是物件4合法引用的物件3,卻被GC給“誤殺”回收掉了。

可以看出,有兩種情況,在三色標記法中,是不希望被髮生的。

  • 條件1: 一個白色物件被黑色物件引用(白色被掛在黑色下)
  • 條件2: 灰色物件與它之間的可達關係的白色物件遭到破壞(灰色同時丟了該白色)
    如果當以上兩個條件同時滿足時,就會出現物件丟失現象!

並且,如圖所示的場景中,如果示例中的白色物件3還有很多下游物件的話, 也會一併都清理掉。

為了防止這種現象的發生,最簡單的方式就是STW,直接禁止掉其他使用者程式對物件引用關係的干擾,但是STW的過程有明顯的資源浪費,對所有的使用者程式都有很大影響。那麼是否可以在保證物件不丟失的情況下合理的儘可能的提高GC效率,減少STW時間呢?答案是可以的,我們只要使用一種機制,嘗試去破壞上面的兩個必要條件就可以了。

五、屏障機制

我們讓GC回收器,滿足下面兩種情況之一時,即可保物件不丟失。 這兩種方式就是“強三色不變式”和“弱三色不變式”。

(1) “強-弱” 三色不變式

  • 強三色不變式

不存在黑色物件引用到白色物件的指標。

強三色不變色實際上是強制性的不允許黑色物件引用白色物件,這樣就不會出現有白色物件被誤刪的情況。

  • 弱三色不變式

所有被黑色物件引用的白色物件都處於灰色保護狀態。

弱三色不變式強調,黑色物件可以引用白色物件,但是這個白色物件必須存在其他灰色物件對它的引用,或者可達它的鏈路上游存在灰色物件。 這樣實則是黑色物件引用白色物件,白色物件處於一個危險被刪除的狀態,但是上游灰色物件的引用,可以保護該白色物件,使其安全。

為了遵循上述的兩個方式,GC演算法演進到兩種屏障方式,他們“插入屏障”, “刪除屏障”。

(2) 插入屏障

具體操作: 在A物件引用B物件的時候,B物件被標記為灰色。(將B掛在A下游,B必須被標記為灰色)

滿足: 強三色不變式. (不存在黑色物件引用白色物件的情況了, 因為白色會強制變成灰色)

偽碼如下:

新增下游物件(當前下游物件slot, 新下游物件ptr) {
//1
標記灰色(新下游物件ptr)

//2
當前下游物件slot = 新下游物件ptr
}

場景:

A.新增下游物件(nil, B) //A 之前沒有下游, 新新增一個下游物件B, B被標記為灰色
A.新增下游物件(C, B) //A 將下游物件C 更換為B, B被標記為灰色

這段偽碼邏輯就是寫屏障,. 我們知道,黑色物件的記憶體槽有兩種位置, . 棧空間的特點是容量小,但是要求相應速度快,因為函式呼叫彈出頻繁使用, 所以“插入屏障”機制,在棧空間的物件操作中不使用. 而僅僅使用在堆空間物件的操作中.

接下來,我們用幾張圖,來模擬整個一個詳細的過程, 希望您能夠更可觀的看清晰整體流程。







但是如果棧不新增,當全部三色標記掃描之後,棧上有可能依然存在白色物件被引用的情況(如上圖的物件9). 所以要對棧重新進行三色標記掃描, 但這次為了物件不丟失, 要對本次標記掃描啟動STW暫停. 直到棧空間的三色標記結束.





最後將棧和堆空間 掃描剩餘的全部 白色節點清除. 這次STW大約的時間在10~100ms間.


(3) 刪除屏障

具體操作: 被刪除的物件,如果自身為灰色或者白色,那麼被標記為灰色。

滿足: 弱三色不變式. (保護灰色物件到白色物件的路徑不會斷)

虛擬碼:

新增下游物件(當前下游物件slot, 新下游物件ptr) {
//1
if (當前下游物件slot是灰色 || 當前下游物件slot是白色) {
標記灰色(當前下游物件slot) //slot為被刪除物件, 標記為灰色
}

//2
當前下游物件slot = 新下游物件ptr
}

場景:

A.新增下游物件(B, nil) //A物件,刪除B物件的引用。 B被A刪除,被標記為灰(如果B之前為白)
A.新增下游物件(B, C) //A物件,更換下游B變成C。 B被A刪除,被標記為灰(如果B之前為白)

接下來,我們用幾張圖,來模擬整個一個詳細的過程, 希望您能夠更可觀的看清晰整體流程。

這種方式的回收精度低,一個物件即使被刪除了最後一個指向它的指標也依舊可以活過這一輪,在下一輪GC中被清理掉。

六、Go V1.8的混合寫屏障(hybrid write barrier)機制

插入寫屏障和刪除寫屏障的短板:

  • 插入寫屏障:結束時需要STW來重新掃描棧,標記棧上引用的白色物件的存活;
  • 刪除寫屏障:回收精度低,GC開始時STW掃描堆疊來記錄初始快照,這個過程會保護開始時刻的所有存活物件。

Go V1.8版本引入了混合寫屏障機制(hybrid write barrier),避免了對棧re-scan的過程,極大的減少了STW的時間。結合了兩者的優點。


(1) 混合寫屏障規則

具體操作:

1、GC開始將棧上的物件全部掃描並標記為黑色(之後不再進行第二次重複掃描,無需STW),

2、GC期間,任何在棧上建立的新物件,均為黑色。

3、被刪除的物件標記為灰色。

4、被新增的物件標記為灰色。

滿足: 變形的弱三色不變式.

虛擬碼:

新增下游物件(當前下游物件slot, 新下游物件ptr) {
//1
標記灰色(當前下游物件slot) //只要當前下游物件被移走,就標記灰色

  //2 
  標記灰色(新下游物件ptr)

  //3
  當前下游物件slot = 新下游物件ptr

}

這裡我們注意, 屏障技術是不在棧上應用的,因為要保證棧的執行效率。

(2) 混合寫屏障的具體場景分析

接下來,我們用幾張圖,來模擬整個一個詳細的過程, 希望您能夠更可觀的看清晰整體流程。

注意混合寫屏障是Gc的一種屏障機制,所以只是當程式執行GC的時候,才會觸發這種機制。

GC開始:掃描棧區,將可達物件全部標記為黑


場景一: 物件被一個堆物件刪除引用,成為棧物件的下游

虛擬碼

//前提:堆物件4->物件7 = 物件7; //物件7 被 物件4引用
棧物件1->物件7 = 堆物件7; //將堆物件7 掛在 棧物件1 下游
堆物件4->物件7 = null; //物件4 刪除引用 物件7

場景二: 物件被一個棧物件刪除引用,成為另一個棧物件的下游

虛擬碼

new 棧物件9;
物件8->物件3 = 物件3; //將棧物件3 掛在 棧物件9 下游
物件2->物件3 = null; //物件2 刪除引用 物件3

場景三:物件被一個堆物件刪除引用,成為另一個堆物件的下游

虛擬碼

堆物件10->物件7 = 堆物件7; //將堆物件7 掛在 堆物件10 下游
堆物件4->物件7 = null; //物件4 刪除引用 物件7

場景四:物件從一個棧物件刪除引用,成為另一個堆物件的下游

虛擬碼

堆物件10->物件7 = 堆物件7; //將堆物件7 掛在 堆物件10 下游
堆物件4->物件7 = null; //物件4 刪除引用 物件7

Golang中的混合寫屏障滿足弱三色不變式,結合了刪除寫屏障和插入寫屏障的優點,只需要在開始時併發掃描各個goroutine的棧,使其變黑並一直保持,這個過程不需要STW,而標記結束後,因為棧在掃描後始終是黑色的,也無需再進行re-scan操作了,減少了STW的時間。

七、總結

以上便是Golang的GC全部的標記-清除邏輯及場景演示全過程。

GoV1.3- 普通標記清除法,整體過程需要啟動STW,效率極低。

GoV1.5- 三色標記法, 堆空間啟動寫屏障,棧空間不啟動,全部掃描之後,需要重新掃描一次棧(需要STW),效率普通

GoV1.8-三色標記法,混合寫屏障機制, 棧空間不啟動,堆空間啟動。整個過程幾乎不需要STW,效率較高。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
劉丹冰Aceld

相關文章