Golang 三色標記和混合寫屏障


一個沒有垃圾回收(Garbage Collection,簡稱GC)機制的編程語言的內存管理問題絕對會讓人頭疼,一個友好的編程語言會設計一個垃圾回收機制——垃圾收集器,來自動回收不再使用的對象和內存空間。

Go 作為一個秉承着“少即是多”理念的編程語言,所以能為開發者考慮到的內容都應該由編程語言自己完成,而不需要開發者手動處理,所以 Go 自然也會有自己的垃圾收集器。

垃圾收集器的簡單實現是暫停程序(Stop The World, 簡稱 STW):

當程序使用的內存越來越多,系統中的垃圾也就增加。當程序內存增漲到一個閾值后,整個程序就全部暫停,垃圾收集器會掃描已經分配的所有對象並回收已經不再使用的內存空間。垃圾回收過程結束后,程序才能繼續執行。

Go 早期也是使用 STW 進行垃圾回收,但現在已經進化了許多。

一、標記清除

標記-清除(Mark-Sweep)算法是常見的垃圾回收算法,主要有兩步:

  1. 標記(Mark phase):從根對象出發查找並標記堆中所有存活的對象
  2. 清除(Sweep phase):遍歷堆中的全部對象,回收未被標記的垃圾對象並將回收的內存加入空閑鏈表

GC開始時會 STW,標記所有不可達對象。

如下圖所示,內存空間中包含多個對象,我們從根對象出發遍歷所有對象及其子對象,並將所有根節點可達的對象都標記為綠色,所有根節點不可達對象標記為白色,這些白色對象就是垃圾:

標記

標記完成后,垃圾收集器會依次遍歷堆中的對象並清除其中的垃圾對象:

清除

只有將標記的所有垃圾對象清除后,程序才能恢復運行。

在程序運行的過程中,會不斷執行這個垃圾回收過程,直到程序退出。

這是最傳統的標記清除算法,因為在垃圾回收過程中需要暫停用戶程序—— STW,這是對資源的浪費,需要用更復雜的機制來解決 STW 的問題。

二、三色標記

為了解決原始標記清除算法帶來的長時間 STW,多數現代的追蹤示垃圾收集器都會實現三色標記算法的變種以縮短 SWT 的時間。

三色標記算法將程序中的對象分為白色、黑色和灰色三類:

  • 白色對象:潛在的垃坡,其內存可能會被垃圾收集器回收
  • 黑色對象:活躍的對象,包括不存在任何引用外部指針的對象以及從根對象可達的對象
  • 灰色對象:活躍的對象,因為存在指向白色對象的外部指針,垃圾收集器會掃描這些對象的子對象

在 GC 開始時,程序中不存在任何的黑色對象,垃圾收集的根對象會被標記為灰色,垃圾收集器只會從灰色對象集合中取出對象開始掃描。當灰色對象集合中不存在任何對象時,即標記完成。

其過程為:

  1. 將根節點標記為灰色,其他節點標記為白色
  2. 在灰色對象中選擇一個標記為黑色
  3. 將黑色對象指向的所有對象都標記為灰色
  4. 重復前兩個步驟直到所有對象中沒有灰色對象
  5. 清除所有白色對象

三色標記

標記階段完成時,應用程序的堆中不存在任何灰色對象,只有黑色的活躍對象和白色的垃圾對象,垃圾收集器就會回收這些白色對象,下圖中的白色對象 \(2\) 就是即將被回收的垃圾:

標記完成

因為用戶程序可能在標記執行的過程中修改對對象的引用,所以三色標記法本身是不可以並發或者增量執行的,仍需要 STW。

在下圖所示的標記過程中,如果將對象 \(1\) 標記為黑色后,根對象 \(R_1\) 引用了對象 \(2\) 。由於對象 \(2\) 是被黑色對象引用而不是被灰色對象引用,所以對象 \(2\) 雖然被引用了,標記點卻已走到了對象 \(1\) ,對象 \(2\) 也就不可能被標記為灰色,直到標記完成時,對象 \(2\) 仍是白色,就會被垃圾收集器錯誤回收。

誤刪對象

一個正常引用的對象被回收是一個非常嚴重的錯誤,這個錯誤被挑不懸掛指針或野指針,即指針指向了非法的內存地址。

為了解決這個問題,需要並發或增量標記對象,手段就是使用寫屏障技術

三、寫屏障技術

內存屏障技術可以讓 CPU 或者編繹器在執行內存相關操作時遵守特定的約束。

目前多數的現代處理器都會亂序執行指令以發揮最大性能,但內存屏障技術能保證內存屏障前的操作一定會在內存屏障后的操作之前執行,不會被編繹器或 CPU 打亂執行順序。

1 三色不變式

想要在並發或增量的標記算法中保證正確性,需要滿足下列兩種三色不變式:

  • 強三色不變式:黑色對象不會指向白色對象,只能指向灰色或黑色對象
  • 弱三色不變式:黑色對象可以指向白色對象,但此白色對象鏈路的上游中必須有一個灰色對象

強三色不變式容易理解,就是上示意圖了,下面是弱三色不變式的示意圖:

弱三色不變式

寫屏障技術就是在並發或增量標記過程中保證三色不變式的重要技術。

2 插入寫屏障

在對象 \(A\) 引用對象 \(C\) 的時候,如果對象 \(C\) 是白色,就將對象 \(C\) 標記為灰色,其他情況則保持不變。

插入屏障

偽碼如下:

writePointer(slot, ptr):
	shade(ptr)
	*slot = ptr

插入寫屏彈是一種相對保守的屏障技術,它會將有存活可能的對象都標記為灰色以滿足強三色不變式

在上圖所示的垃圾回收過程中,實際上不再存活的的對象 \(B\) 也保留到了最后,沒有被回收。如果在第二步時再指 \(A\)\(C\) 的指針指向 \(B\) ,雖然 \(C\) 沒有被任何對象引用,但其依然是灰色,不會被回收,只有在下次 GC 時才會被回收。

2.1 優點

  • 性能優勢

不需要對指針進行任何處理,因為指針的讀作操作通常比寫操作高出一個或多個數量級

  • 前進保障

對象可以從白色到灰色單調轉換為黑色,因此總工作量受到堆大小的限制

2.2 缺點

  • 由於過於保守,可能會有一部分被誤染黑的垃圾對象,只能在下次垃圾回收時被回收
  • 在標記過程中,每次進行指針賦值操作時,都需要引入寫屏障,會大大增加性能開銷。如果關閉棧上的寫屏障,當棧上新創建對象時,將新創建的棧對象直接標記為灰色,但這樣會產生額外的灰色對象,需要在標記結束前 STW 對棧上的對象重新掃描

3 刪除寫屏障

刪除屏障技術,又稱基於起始快照的屏障。其思想是:

當白色或灰色的對象的引用被刪除時,將白色對象變為灰色。

刪除屏障

刪除寫屏障通過對對象 \(C\) 的着色,保證了對象 \(C\) 和下游的對象 \(D\) 能夠在這一次垃圾收集的循環中存活,避免發生野指針以保證用戶程序的正確性。

這樣刪除寫屏障就可以保證弱三色不變式,能夠保證白色對象的上游鏈路中一定存在灰色對象。

四、增量和並發

傳統的垃圾收集算法會在垃圾回收的執行期間 STW,一旦觸發垃圾回收,垃圾收集器會搶占 CPU 的使用權,占據大量的計算資源以完成標記和清除工作。然而,對於追求實時性的應用程序是無法接受長時間的 STW 的。

以前的計算資源不像現在這么充足,現在的計算機基本都是多核處理器,垃圾收集器一旦開始執行,就會浪費大量的計算資源,為了減少應用程序暫停的時間和垃圾收集的總暫停時間,可以使用下面的策略優化現在的垃圾收集器:

  • 增量垃圾回收:增量地標記和清除垃圾,降低用戶程序暫停的最長時間
  • 並發垃圾回收:利用多核的計算資源,在用戶程序執行時並發標記和清除垃圾

因為增量和並發兩種方式都可以與用戶程序交替進行,所以我們需要使用寫屏障技術保證垃圾回收的正確性。與此同時,應用程序也不能等到內存溢出時觸發垃圾回收,因為當內存不足時,應用程序已經無法分配內存,這與暫停程序沒有區別。

增量和並發的垃圾回收需要提前觸發,並在內存不足前完成整個循環,避免程序的長時間暫停。

1 增量回收

增量式的垃圾回收是減少程序最長暫停時間的一種方案,它並不會等 GC 執行完,才將控制權交回程序,而是一步一步執行,跑一點,再跑一點,逐步完成垃圾回收,在程序運行中穿插進行,極大地降低了 GC 的最大暫停時間。

增量垃圾回收需要與三色標記法一起使用,為了保證垃圾回收的正確性,需要在垃圾收集開始前打開寫屏障,這樣用戶程序修改內存會先經過寫屏障的處理,保證堆內存中對象關系的強三色不變式或弱三色不變式。

雖然增量垃圾回收能夠減少最大暫停時間,但也會增加一次 GC 循環的總時間。在垃圾回收期間,因為寫屏障的影響,用戶程序也需要承擔額外的計算開銷,所以增量式的垃圾回收也有缺點,但總體來說是利大於弊。

2 並發回收

並發式的垃圾回收不僅能夠減少程序的最大暫停時間,還能減少整個垃圾回收階段的時間,通過開啟讀寫屏障、利用多核優勢與用戶程序並行執行

雖然並發回收能夠與用戶程序一起運行,但是並不是所有階段都可以與用戶程序一起運行,部分階段仍需要暫停用戶程序。不過與傳統的算法相比,並發回收可以將能夠並發執行的工作盡量並發執行。

當然,因為讀寫屏障的引入,並發的垃圾回收也一定會帶來額外開銷,不僅會增加垃圾回收的總時間,還會影響用戶程序。

五、混合寫屏障

在 Go 語言 v1.7 版本之前,運行時會使用插入寫屏障保證強三色不變式,但是運行時並沒有在所有的垃圾回收根對象上開啟寫屏障。因為應用程序可能包含成百上千個 Goroutine,而垃圾回收的根對象一般包括全局變量和棧對象。

如果運行時需要在幾百個 Goroutine 的棧上都開啟寫屏障,會帶來巨大的額外開銷,所以 Go 團隊在實現上選擇了在標記階段完成時暫停程序、將所有棧對象標記為灰色並重新掃描。在活躍的 Goroutine 非常多的程序中,重新掃描的過程需要 10 ~ 100ms 的時間。

在 v1.8 版本中,由插入寫屏障和刪除寫屏障構成了如下所示的混合寫屏障,其流程如下:

  • GC 開始,將棧上的全部可達對象標記為黑色,之后便不再需要進行重新掃描
  • GC 期間,任何在棧上新創建的對象都標記為黑色
  • 寫屏障將被刪除的對象標記為灰色
  • 寫屏障將新添加的對象標記為灰色

寫屏障不在棧上應用

writePointer(slot, ptr):
    shade(*slot)
    shade(ptr)
    *slot = ptr

下面是一些混合寫屏障的場景示意圖。

場景一

對象被一個堆對象刪除引用,被一個棧對象引用。

第一步,將棧上可達對象全部標記為黑色:

場景1-1

第二步,對象 \(6\) 被對象 \(1\) 引用:

場景1-2

  • 寫屏障在作用在棧上,所以對象 \(6\) 直接被對象 \(1\) 引用。

第三步,斷開與對象 \(5\) 的引用關系:

場景1-3

  • 對象 \(5\) 刪除與對象 \(6\) 的引用關系,觸發寫屏障,將對象 \(6\) 標記為灰色

場景二

對象被一個棧對象刪除引用,被另外一個棧對象引用。

第一步,棧上新建對象 \(5\)新創建的對象是黑色

場景2-1

第二步,對象 \(5\) 引用對象 \(3\)

場景2-2

  • 直接引用,棧上沒有寫屏障

第三步,刪除對象 \(2\) 和對象 \(3\) 之間的引用關系:

場景2-3

  • 直接刪除,棧上沒有寫屏障


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM