[翻译]在GC上加入DPAD
本文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大佬其實透露三個重要的信息:
段內存分配的方式結束了,將使用區域的方式來替代段內存分配。
.NET 6.0中大概率不會支持區域,但是會通過clrgc.dll的方式獨立提供,你可以通過配置的方法打開,大家要注意這個獨立提供,因為從.NET Core 2.1開始我們就可以自定義GC了,也就是說你開心的話,可以自己寫一個GC,然后替換掉.NET自帶的GC;使用的環境變量是這個link,另外也有大佬實現了一個Zero GC?link,你只需要實現幾個接口,就可以自定義GC。
以后.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對象、固定對象堆等等
https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/fixed-statement
https://zhuanlan.zhihu.com/p/376419012
有了區域,各代人看起來是統一的,我們不再有這種 "短暫的片段 "概念。我們有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執行流程、問題診斷進行介紹,是一本不可多得的好書。
其它文章
Go與C#比較 - 編譯、運行時、類型系統、模塊和其它的一切
Go與C#比較 - 垃圾回收
總結
以上是生活随笔為你收集整理的[翻译]在GC上加入DPAD的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: NET问答: C# 中是否有 forma
- 下一篇: 基于ABP落地领域驱动设计-05.实体创