本文90%通過機器翻譯,另外10%譯者按照自己的理解進行翻譯,和原文相比有所刪減,可能與原文並不是一一對應,但是意思基本一致。

譯者水平有限,如果錯漏歡迎批評指正

譯者@Bing Translator@InCerry,另外感謝@Hex@曉青@賈佬@黑洞百忙之中抽出時間幫忙review和檢查錯誤。

原文連結:https://devblogs.microsoft.com/dotnet/put-a-dpad-on-that-gc/


這是在說什麼?是的,我們有一個在區域【原文叫region】上叫做DPAD的新功能。區域是我們目前在.NET 6中用於替換段【原文叫segment】的新東西。在這篇博文中,我將首先對區域做一些介紹,然後談談DPAD功能。請注意,我們不太可能在.NET 6.0結束時正式支援區域,因為這涉及到很多工作--我們目前的計劃是在clrgc.dll中把它作為一個實驗性的功能,你可以通過配置來開啟。事實上,這就是我希望從現在開始的大型GC功能的釋出方式,我們首先將它們與獨立的GC一起釋出(即在clrgc.dll中),這樣人們就可以嘗試它們,然後我們在coreclr.dll中正式開啟它們,這樣它們就預設開啟了。

譯者注:

原本.NET的GC是分段式GC,也就是說GC管理記憶體的單位是段,而現在改了,改成區域了,另外這一段中Maoni大佬其實透露三個重要的資訊:

  1. 段記憶體分配的方式結束了,將使用區域的方式來替代段記憶體分配。
  2. .NET 6.0中大概率不會支援區域,但是會通過clrgc.dll的方式獨立提供,你可以通過配置的方法開啟,大家要注意這個獨立提供,因為從.NET Core 2.1開始我們就可以自定義GC了,也就是說你開心的話,可以自己寫一個GC,然後替換掉.NET自帶的GC;使用的環境變數是這個link,另外也有大佬實現了一個Zero GC link,你只需要實現幾個介面,就可以自定義GC。
  3. 以後.NET上GC重大功能的釋出都會遵循這樣一個步驟:功能開發 => 單獨釋出到clrgc.dll => 公開測試修復bug => 正式釋出到coreclr.dll

到目前為止,如你所知,我們一直在段上運作。段多年來為我們提供了很好的服務,但我開始注意到它的侷限性,因為人們把更多種類的工作負載放在我們的框架上。段是我們記憶體管理的基礎,所以從段轉換成區域是件大事。當我們接近.NET 6釋出時,我決定是時候擺脫段式了,所以這是我們的團隊最近花費大量時間的地方。那麼,段和區域之間的主要區別是什麼?段是大的記憶體單位--在Server GC 64-bit上,如果段的大小是1GB、2GB或4GB(在工作站模式下更小-256MB),而區域是小得多的單位,它們預設為每個4MB。所以你可能會問,"所以它們更小,為什麼有意義?"。要回答這個問題,首先讓我們回顧一下段是如何工作的。

如果您看不明白上面的這一段文字,那麼建議您先補一下基礎的知識,微軟的官方文件。裡面詳細的介紹了.NET GC的基礎知識,包括什麼是分代、垃圾回收的過程、伺服器GC與工作站GC、併發GC、後臺GC等等。

目前,當我們只有一個段時,SOH在堆上是這樣的:

當我們有多個段時,它可以看起來像這樣

或這樣

藍色和黃色的空間是一個段上所有已提交【已提交:是指由作業系統分配給應用程式使用的記憶體】的記憶體(關於Gen【代】開始的解釋,請看這個視訊,)。每個段都會記錄該段上已提交的內容,以便我們知道是否需要提交更多。而該段上的所有空閒空間也是已提交的記憶體。當我們使用空閒空間來容納物件時,這很有效,因為我們可以立即使用記憶體--它已經被提交。但是想象一下這樣的場景:我們在某一代有空閒空間,比如說gen0,因為有一些非同步IO正在進行,導致我們在gen0中降級了一堆pin物件,但我們實際上並沒有使用(這可能是由於沒有等待這麼長時間來做下一次GC,或者我們已經積累了太多的活著的物件,這意味著GC暫停會太長)。如果我們能將這些空閒空間用於其他代,如果他們需要的話,那不是很好嗎?gen2和LOH中的空閒空間也是一樣的--你可能在gen2中有一些空閒空間,如果能用它們來分配一些大的物件就好了。我們在段上做撤銷提交【uncommit:已提交的反向操作】,但只是在段的末端,也就是在該段上最後一個活物件之後(由每個段末端的淺灰色空間表示)。而如果你有pin物件,就阻止了GC收回段的末端,那麼只能形成自由空間,而自由空間裡是已提交的記憶體。當然,你可能會問,"為什麼不直接把有大量自由空間的段的中間部分取消提交?"。但這需要記錄,以記住段中間的哪些部分被解密,所以當我們想用它們來分配物件時,我們需要重新提交它們。而現在,我們已經進入了區域的概念,也就是讓更小的記憶體量被GC單獨操作。

如果您看不懂上面這段文字,那麼說明您需要翻閱一下下面這些資料,來了解已提交記憶體、pin物件、固定物件堆等等

有了區域,各代人看起來是統一的,我們不再有這種 "短暫的片段 "概念。我們有gen0和gen1區域,就像我們有gen2區域一樣。

當然,每一代的區域數量可能有很大的不同。但它們都由這些小的記憶體單元組成。LOH的區域確實更大(LOH是SOH區域大小的8倍,所以每個32MB)。當我們釋放一個區域時,我們將其返回到自由區域池中,該池中的區域可以被任何一代抓取,甚至在需要時被任何其他堆抓取。因此,你不會再看到這樣的情況:你在gen2或LOH中有一些巨大的空閒空間,但它們很長時間都沒有被使用(如果你的應用程式的行為經歷了一些階段,其中一個階段可能比另一個階段生存更多的記憶體,而GC認為沒有必要做一個完整的壓縮GC,這種情況就可能發生)。

在GC工作中,我們總是要做出權衡。有了區域,我們確實獲得了很多靈活性。但我們也不得不放棄一些東西。有一件事使段非常有吸引力,那就是我們確實有一個連續的短暫範圍,因為gen0和gen1總是生活在短暫的段上,而且總是緊挨著。當我們在寫 屏障中設定卡片時【在GC有一個card tables,用來記錄物件之間的跨代引用,另外就是實現寫屏障,詳細可以翻閱《.NET Core底層入門》P289】,我們利用了這個優勢。如果你做obj0.f = obj1,並且我們檢測到obj1不在短暫的範圍內。我們不需要設定卡片,因為我們不需要它(只有當obj1比obj0處於更年輕的一代時才需要設定卡片,如果obj1不在短暫的範圍內,這意味著它要麼在gen2,要麼在LOH/POH,這些都被邏輯上認為是第二代的一部分(但內部被追蹤為gen3和gen4,我在這篇文章中互換使用LOH和gen3)。而這意味著它要麼與obj0處於同一代,要麼處於比obj0更早的一代)。) 但是我們只對工作站GC做了這個優化,因為伺服器GC有多個短暫的範圍,我們不想在寫屏障程式碼時要和所有的範圍進行比較。在區域中,我們要麼無條件地設定卡片(這將使Workstation GC的暫停倒退一些,但對Server GC保持相同的效能),要麼在寫屏障中檢查obj1的區域,這將比在最優化的寫屏障型別中檢查短暫範圍更昂貴。不過區域帶來的好處應該比這更有說服力。

現在我們可以談一談DPAD功能。DPAD是動態升級和降級的意思。嚴格來說,降級已經是動態的了,因為它只根據Pin物件的情況動態發生。如果你讀過我的備忘錄,那裡解釋了降級(如果你沒讀過,我強烈建議你讀mem-doc)。基本上,降級意味著一個物件不會像正常情況下那樣得到提升。對於段來說,降級意味著我們將暫存段的一個範圍設定為 "降級範圍",這個範圍只能從暫存段的中間一點到該段的末端。換句話說,我們永遠不會把短暫段中間的一個範圍設定為降級範圍。這正是因為對於段,gen1必須在短暫段的gen0之前(在同一個堆上)。所以我們不能有一個gen1的部分,接著是gen0的部分,然後再接著是gen1的部分。

升級是GC中一個常見的概念--它意味著如果一個物件存活了一代,它現在被認為是上一代的一部分。因此,如果你在SOH上有一個長期生存的小物件,它最終會被提升到gen2。但這意味著這需要2次GC才能實現。我正計劃提供一個API,讓使用者可以選擇告訴GC將一個新的物件直接分配到某一代,所以你可以將你知道會存活到gen2的物件直接分配到gen2中(到目前為止我還沒有實現這個API,因為有區域的支援也會更容易,所以我正計劃在我們轉換到區域時實現它)。但這並不包括所有的情況,因為有時使用者很難知道一個物件是否會 "很可能存活到gen2"。而且你可能正在使用一個庫,對這些物件的分配沒有控制。一個非常明顯的情況是,這種情況會發生在資料基礎設施的大小調整上。比方說,你或你使用的庫分配了一個List,它需要增加容量。所以它分配了一個新的T[]物件,可以容納兩倍於舊物件的元素數量。現在它為第二部分建立了一堆子元素。現在,如果新的陣列足夠大,可以上LOH,而且新的子元素都是小物件,所以它們在gen0 -

通過上文的描述,Maoni大佬的團隊計劃實現一個GC的API,可以讓使用者指定你的物件分配到某一代中(預設都是從G0開始)。

比如我們經常會有這樣一些場景,我們在程式啟動的時候會去讀一些資料,將它們快取到記憶體中,這些快取直到程式關閉才會釋放,也就是說開發者能知道最終它會到gen2;如果沒有這個API,那麼你快取的物件將從gen0開始,經過兩次GC才到gen2,一般快取的資料都比較大,導致GC在標記和整理過程中會花更多的實際,而且可能由於可用記憶體不足,會頻繁的去申請空間;如果有了這個API,開發者就能將物件直接分配到gen2,避免了gen0和gen1的GC,也避免了頻繁擴容空間。

(為了說明問題,我只展示了一個8元素的陣列和4個新的孩子,如果這是一個物件[],顯然它需要更多的元素才能進入LOH)

在片段的情況下,我們會看到這樣的情況:

由於新的陣列被認為是gen2的一部分,這意味著所有在gen0中建立的新元素都將存活到gen2中(除非gen2的GC很快發生,並發現父陣列已經死亡,這有可能發生,但可能性不大;如果真的發生,那就非常不幸了,因為你花了這麼大代價建立一個大物件,卻馬上把它拋棄)。但要做到這一點,它至少需要經過兩次GC。我們很有可能首先觀察到一個gen0或gen1的GC,這個GC會讓這些孩子生存到gen1。

然後下一個gen1的GC會發現他們都還活著,因為他們被LOH中的那個陣列保持著活力。現在它把它們都提升到Gen2

在這種情況下,我們更願意直接將它們分配到gen2。但是這對段來說是很難做到的。我們可以跟蹤哪些物件由這些物件組成,或者主要由這些物件組成,但是當我們做標記時,我們不知道哪些物件會一起形成插頭【Plug,被翻譯成插頭,詳情可以看《.NET記憶體管理寶典》P371和《.NET Core底層入門》P323】。而當我們在形成插頭時,我們已經失去了這些資訊。我們可以在更大的顆粒度上跟蹤這些資訊。但你猜怎麼著,這基本上就像區域一樣!因為我們想把這些資訊劃分到不同的區域。因為我們想把一個區段劃分成更小的單位來跟蹤這些資訊。所以對於區域來說,這是很容易的。當我們做標記時,我們確切地知道每個區域上有多少存活下來的東西--當我們標記每個物件時,我們跟蹤我們需要把存活下來的位元組歸於哪個區域。所以我們知道有多少存活是由卡片標記完成的。

對於區域,當我們遇到一個主要由物件組成的區域時,如這些因卡片標記而被保留的子物件,我們有一個選擇:

我們可以選擇將這個區域直接分配到gen2 :

因此,該區域被併入gen2。屬於gen0的另一個區域的倖存者被壓縮到gen1區域,gen0得到一個新的區域用於分配。

在目前的實現中,我只對那些主要被像這樣的物件填滿的區域做了這個工作。由於區域很小,很可能有些區域被這些東西填滿,然後我們有另一個區域部分被這些東西填滿,部分被一些真正的臨時物件填滿。把它們分開的複雜性是不值得的(你可以把它看作是我們回到了這個特定區域的片段情況)。

當我們這樣做時,會有一些複雜的情況(對於GC來說,幾乎總是有一些複雜的情況......)。一個例子是,由於我們現在只是讓gen0的物件在gen2中生存,我們需要確保如果它們指向任何不是gen2的代,就需要為這些物件設定卡片。當我們在重新定位階段通過活著的物件時,我們會這樣做(因為無論如何我們已經必須通過每個物件)。

所以雙關語(部分)的意思是,這個DPAD功能有點像D-pad......你可以告訴一個區域它需要去哪個方向--向上或向下(在GC術語中是指年長或年輕)。有很多情況下,我們想動態地提升或降低一個區域,我上面舉的例子只是其中之一。重點是,有了區域,我們可以動態地指定一個我們希望一個區域最終處於的代數,因為代數不再是連續的,而且沒有特定的順序,代數必須是相對的(當然,正如你在上面看到的,有一些實施細節需要為不同的場景所關注)。這比我們以前用分段做的有限的降級要靈活得多。而當我們在GC結束時對區域進行執行緒化處理時,我們只需要將它們執行緒化到它們所分配的區域。隨著我對DPAD的初步檢查,我已經實現了3個場景,我們將動態地促進或降級區域。在未來,我們會實現更多。

譯者注

從maoni大佬的這篇文章我們可以看到主流的GC設計都越來越趨於一致了,第一眼看到region的時候我就想到了JVM上的ZGC(多佔用一些記憶體和犧牲一定的吞吐量來達到亞毫秒級的STW時間),而目前看來.NET也在做類似的事情,不過我也不敢肯定,那麼region能為我們帶來什麼呢,有得也有失:

  • 通常情況下會有更少的記憶體佔用,特殊情況下更多的記憶體佔用。因為region的一般來說只有4MB大小,而segment會有1GB~4GB大小,另外對於pin住的物件,segment也不能很好的進行處理,從而造成了記憶體碎片,會佔用跟多的空間;region還有的有點就是釋放後會返回到一個池中,哪個代需要使用就可以分配給哪個代,這比segment模式更加靈活,更能複用已申請的記憶體。為什麼會說通常情況下,那是因為同樣使用1GB記憶體region的數量肯定比segment要多,所以需要有額外的空間來記錄region的引用,當堆很大(比如TB級別以上),可能會佔用更多的記憶體。
  • 更少的STW時間。region很小,所以進行"標記-整理"中整理的步驟時,可以將整個region升代,加快了整理的的速度。
  • 吞吐量的下降。由於region上gen0和gen1不會在連續的地址空間上,所以記憶體屏障付出的代價會更大,從而造成吞吐量的下降,在此之前.NET的GC都是為吞吐量和P99延時優化的。

現在關於DPAD的程式碼已經合併到main分支中了,詳情可以看這個PR,相信很快就能和我們見面,不過看了maoni大佬提交的程式碼,發現個有趣的東西。

  • Region只支援64位作業系統。從下圖中的提交來看,限定了只有64位作業系統才能使用,讓我不禁想到ZGC的染色指標,通過染色指標來減少寫屏障的使用,進一步降低STW時間。如果支援了染色指標那麼標記可能也會採用三色標記,主流的GC演算法也趨於一致了。

  • 和Hex大佬討論了一下,後面覺得除了azul的C4演算法以外.NET GC也有可能會採用CoCo演算法來實現,CoCo也是一種低延時的演算法,具體可以看看這篇論文,而且已經有人在.NET上實現了這個。

如果您想更詳細的瞭解.NET的GC和整個實現的原理,您可以看.NET Runtime部分的原始碼和.NET GC架構師Maoni大佬的部落格,另外也有兩本不錯的書推薦。

  • 《.NET Core底層入門》:由國內精通C++ 彙編的大佬從2017年閱讀CLR原始碼後編寫,寫的十分詳細並且具有極大的參考意義,主要是介紹CoreCLR的,中間GC的部分也寫的很清楚。

  • 《.NET 記憶體管理寶典》:由國外研究.NET GC的大佬編寫,主要圍繞著.NET的記憶體分配、GC執行流程、問題診斷進行介紹,是一本不可多得的好書。

其它文章