Go垃圾回收系列之九:內(nèi)存屏障
并發(fā)標(biāo)記由于標(biāo)記協(xié)程與用戶協(xié)程共同工作,帶來了很多難題。如果說輔助標(biāo)記是為了保證垃圾回收能夠正常的結(jié)束與循環(huán)、那么本小節(jié)將解決更棘手的問題:準(zhǔn)確性。如下所示,假設(shè)在垃圾收集已經(jīng)掃描完根(此時根對象都為黑色),并繼續(xù)掃描期間,白色對象Z正被一個灰色對象引用。但是此時,工作協(xié)程在執(zhí)行過程中,讓黑色的根對象指向了白色的對象Z。由于黑色的對象不會被掃描,這將導(dǎo)致白色對象Z被視為垃圾對象,最終被回收。這就導(dǎo)致了致命的錯誤。

那么是不是黑色對象一定不能夠指向白色對象呢?其實也不一定。如下所示,即便是黑色對象引用了白色對象,但只要是白色對象有一條路勁被灰色對象引用了,那么此白色對象也一定能夠被掃描到。

這其實引出了并發(fā)標(biāo)記要保證準(zhǔn)確性,即垃圾收集器能夠掃描到所有活著的對象,需要遵守的原則。
這就是強弱三色不變性。
強三色不變性 指的是 所有白色的對象都不能夠被黑色的對象引用,這是一種比較嚴(yán)格的保證。與之對應(yīng)的弱三色不變性。
弱三色不變性允許白色對象被黑色對象引用,但是白色對象指向有一條路徑,最終是被黑色對象引用的,這保證了該對象最終能夠被掃描到。
在并發(fā)標(biāo)記寫入和刪除對象時,可能會破壞三色不變性,因此必須要有一種機制能夠維護三色不變性,這就是屏障策略。屏障策略的原則是保護活著的對象在寫入或者能夠刪除對象的時候?qū)⑦m當(dāng)?shù)膶ο笞優(yōu)榛疑珜崿F(xiàn)的。例如上例中,如果能夠在對象寫入時, 將Z對象設(shè)置為灰色,那么Z對象將最終被掃描到。

上圖提到的這種簡單的屏障技術(shù)是Dijkstra[1976, 1978]風(fēng)格的插入屏障,其實現(xiàn)形式如下,如果目標(biāo)對象src為黑色,則將新引用的對象標(biāo)記為灰色。
Write(src, i, ref):
src[i] ← ref
??if isBlack(src)
????shade(ref)又如另一種常見的策略是在刪除引用的時候做文章,Yuasa [1990]刪除寫屏障一旦取消了原引用后,就立即將原引用標(biāo)記為灰色。

這樣即便沒有寫屏障,插入時也不會破壞三色不變性,如下所示。但是Z對象可能是垃圾對象。

插入屏障與刪除屏障通過在寫入和刪除時重新標(biāo)記顏色保證了三色不變性,解決了并發(fā)標(biāo)記期間的準(zhǔn)確性問題,但是他們都存儲浮動垃圾的問題。插入屏障在刪除引用的時候,可能一個已經(jīng)變成垃圾的對象仍然被標(biāo)記了。而刪除屏障在刪除了刪除引用的時候可能把一個垃圾對象標(biāo)記為了灰色。這叫做垃圾回收的精度問題但是不會影響其準(zhǔn)確性。因為浮動垃圾會在下一次垃圾回收中被收集。
插入屏障與刪除屏障獨立存在并能夠良好工作的前提是對于并發(fā)標(biāo)記期間所有的寫入都應(yīng)用了屏障技術(shù),但現(xiàn)實情況不會如此。大多數(shù)垃圾回收語言不會對棧上的操作或寄存器上的操作進(jìn)行相同的屏障技術(shù),因為棧上操作是最頻繁的,每個寫入或刪除操作應(yīng)用屏障技術(shù)會大大減慢程序的速度。所以在Go1.9之前,盡管應(yīng)用了插入屏障,但是仍然需要在標(biāo)記終止期間STW階段重新重新掃描根對象,來保證三色標(biāo)記的一致性。而Go1.9之后,使用了混合寫屏障技術(shù),結(jié)合了類似Dijkstra 與 Yuasa 兩種風(fēng)格。為了了解為什么需要混合寫屏障技術(shù),首先來看一看單純的插入屏障和刪除屏障在現(xiàn)實中的困境。
假設(shè)棧上一開始的情況如下圖,棧上變量P指向了堆區(qū)內(nèi)存。假設(shè)現(xiàn)在垃圾回收掃描完了根對象,這時old變量是不會被掃描的,同時進(jìn)入到了標(biāo)記階段

在并發(fā)標(biāo)記階段,old對象引用了p.x,但是賦值給棧上的變量不會經(jīng)過寫屏障。如果下一步,p.x引用了一個新的內(nèi)存對象k,并把k標(biāo)記為灰色,但是并不把原始的對象標(biāo)記為灰色,這時,原始的對象雖然被棧上的對象old標(biāo)記了,但是卻無法被掃描到,因此會出現(xiàn)致命問題。所以必須在p.x=&k應(yīng)用刪除屏障,在取消引用時,將p.x的原值標(biāo)記為灰色。

如果只有刪除屏障而沒有寫屏障,也會面臨問題:
如下,假設(shè)一開始根對象還沒開始掃描,全為白色對象時的情形,棧上變量p引用堆區(qū)o,棧上變量a引用堆區(qū)k, 在并發(fā)標(biāo)記期間,假設(shè)開始掃描過了變量p還沒開始變量a時的情形如下。

此時,工作協(xié)程將變量a置為nil,p.x = &k將對象p指向了k。此時如果只有刪除屏障而不啟用寫屏障(不標(biāo)記新的值k),會看到當(dāng)前直接違背三色不變性,讓黑色的對象引用了白色的對象。這會導(dǎo)致k無法被標(biāo)記。

因此,要想在標(biāo)記終止階段不用重新掃描根對象,需要使用寫屏障與刪除屏障混合的屏障技術(shù),其偽代碼如下所示:
writePointer(slot, ptr):
shade(*slot)
shade(ptr)
*slot = ptr在Go語言中,混合屏障依賴于編譯時與運行時的共同努力。在標(biāo)記準(zhǔn)備階段的STW階段會打開寫屏障,具體是讓全局變量writeBarrier.enabled設(shè)置為true.
var writeBarrier struct {
enabled bool
pad [3]byte
needed bool
cgo bool
alignme uint64
}編譯器會在所有堆寫入或刪除操作前判斷當(dāng)前是否為垃圾回收標(biāo)記階段,如果是則會執(zhí)行對應(yīng)的混合寫屏障標(biāo)記對象。在匯編代碼中表示如下,其中g(shù)cWriteBarrier是與平臺相關(guān)的操作,執(zhí)行標(biāo)記邏輯。
CMPL runtime.writeBarrier(SB), $0
CALL runtime.gcWriteBarrier(SB)Go語言構(gòu)建了一個寫屏障指針的緩存池,gcWriteBarrier首先所有被標(biāo)記的指針會放入到緩存池中,并在容量滿之后,一次性全部刷新到掃描工作池中。

推薦閱讀
