原文連結:https://adamsitnik.com/Array-Pool/
第一次翻譯,會有較多機翻,如果有錯誤,請及時指出批評,我會立即改正。
使用ArrayPool來避免大陣列造成的Full GC的問題。
簡介
.NET的垃圾收集器(GC)實現了許多效能優化,其中之一就是,設定年輕的物件很快消亡,然而老的物件卻可以生存很久。這就是為什麼託管堆被劃分為三個代。我們稱呼他們為第0代(最年輕的)、第1代(短暫生存)、第2代(生存最長的)。新的物件預設都被分配到第0代。當GC嘗試分配一個新的物件到第0代時並且發現第0代已經滿了,就會觸發第0代進行回收,這個被稱呼為區域性回收(僅僅回收第0代)。GC遍歷整個物件圖形,從最根部(區域性變數,靜態欄位等)開始,將所有的引用物件標記為生存物件。
以上是第一階段,被稱為“標記”階段,此階段為非阻塞的。但是GC回收程式是阻塞的,GC會掛起所有的執行緒來執行下一步。
生存了的物件被提權(提權過程大部分時間都是消耗在資料拷貝上)到第1代,然後第0代被清空。第0代往往被設計為很小,所以執行第0代的回收會比較快。理想情況下,一個WEB請求,從開始請求到結束請求,所有被分配的物件都應該被回收掉。然後GC就可以將下一個物件指標移到第0代的起始位置。同理,根據第0代的回收邏輯,當第1代也滿了之後,GC就不能再將第0代的物件進行提權到第1代了。接著GC就開始回收第1代的記憶體。第1代也很小,執行回收也很快,緊接著,第1代的生存者被提權到第2代。第2代裡面都是生存期很長的物件,第2代非常大並且執行第2代的垃圾回收會非常非常耗時。所以針對於第2代的垃圾回收我們應該儘量避免,想知道為什麼?讓我們看看下面的視訊然後看看第2代的垃圾回收是如何影響使用者體驗的。
大物件堆疊(LOH)
每當GC將物件轉移到新的一代時,都會進行記憶體拷貝。如你想象,如果是在拷貝一些大物件,例如大陣列或者字串時會尤其耗時。為了解決這種問題,GC有另一個優化手段,任何一個大於85000位元組的物件都被認為是大物件,大物件儲存在託管堆的單獨部分中,稱為大物件堆(LOH),該部分使用自由列表演算法進行管理。這意味著GC有一個免費的記憶體段列表,當我們想要分配一些大的內容時,它會搜尋列表以找到一個可行的記憶體段。因此,預設情況下,大物件永遠不會在記憶體中移動
。然而,如果遇到LOH碎片問題,則需要壓縮LOH。從.NET 4.5.1開始,您可以按需執行此操作。
問題來了
分配大物件時,它被標記為GC的第2代物件。不像小物件是預設放在第0代的。這種機制的結果就是如果你在LOH中耗盡記憶體,GC會清理整個託管堆(第0代、第1代、第2代以及LOH塊),而不僅僅是LOH。這種行為被稱為Full GC,是最為耗時的垃圾回收。對於許多應用,Full GC可以忍受,但是對於高效能的WEB伺服器,實在是無法忍受,其中需要很少的大記憶體緩衝來處理平均的Web請求(例如從套接字讀取,解壓縮,解碼JSON等等)。
要是想知道Full GC是不是你的應用效能問題,可以用內建的perfmon.exe程式獲得簡單的檢視報告。
如你所見,對於我的Visual Studio程式來說,Full GC不是問題,我的Visual Studio應用程式已經執行了好幾個小時了,第2代的回收相比於第0、1代來說要少很多。
解決方案
解決方案非常簡單:緩衝池。 池(Pool)是一組可以使用的初始化物件。我們不是分配新物件,而是從池中租用它。一旦我們完成使用,我們就將它返回到池中。每個大型託管物件都是一個陣列或陣列包裝器(字串包含一個長度欄位和一個字元陣列)。所以我們需要池陣列來避免這個問題。
ArrayPool
程式碼示例
var samePool = ArrayPool<byte>.Shared;
byte[] buffer = samePool.Rent(minLength);
try
{
Use(buffer);
}
finally
{
samePool.Return(buffer);
// don't use the reference to the buffer after returning it!
}
void Use(byte[] buffer) // it's an array
如何使用
首先你需要一個初始化的池,至少有三種方式可以獲得:
- 最建議的方式:使用 ArrayPool
.Shared 屬性,它將返回一個執行緒安全的可共享的池物件例項,不過要記住他有一個預設的最大陣列長度( 2^20 (1024*1024 = 1 048 576))。 - 使用 ArrayPool
.Create靜態方法,也可以建立一個執行緒安全的池,並且可以自定義maxArrayLength和maxArraysPerBucket兩個引數,如果最大陣列長度對你來說不夠的話,你可以嘗試使用。不過請記住,一旦你建立了它,你有責任讓它保持活力。 - 從抽象ArrayPool
派生自定義類並且自己實現處理機制。
接下來,在獲取了初始化池之後你就需要呼叫Rent
方法,它需要你傳入一個你想要的快取的最小長度,請記住,Rent
返回的內容可能比您要求的要大。
byte[] webRequest = request.Bytes;
byte[] buffer = ArrayPool<byte>.Shared.Rent(webRequest.Length);
Array.Copy(
sourceArray: webRequest,
destinationArray: buffer,
length: webRequest.Length); // webRequest.Length != buffer.Length!!
完成使用後,只需使用Return
方法將其返回到相同的池中即可。Return
方法有一個過載,它允許你清理緩衝區,以便後續的消費者呼叫Rent
方法不會看到以前的消費者的內容。預設情況下,內容保持不變。
原始碼中有一段關於ArrayPool的一個非常重要的備註
Once a buffer has been returned to the pool, the caller gives up all ownership of the buffer and must not use it. The reference returned from a given call to Rent must only be returned via Return once.
這意味著,開發人員需要正確使用此功能。如果在將緩衝區返回到池後繼續使用對緩衝區的引用,則存在不可預料的風險。據我所知,截止至今天來說還沒有一個靜態程式碼分析工具可以校驗正確的用法。 ArrayPool是corefx庫的一部分,它不是C#語言的一部分。
壓測
讓我們使用BenchmarkDotNet來比較使用new操作符分配陣列和使用ArrayPoolRent
和Return
的消耗,我正在執行.NET Core 2.0的基準測試,這很重要,因為它具有更快的ArrayPool
class Program
{
static void Main(string[] args) => BenchmarkRunner.Run<Pooling>();
}
[MemoryDiagnoser]
[Config(typeof(DontForceGcCollectionsConfig))] // we don't want to interfere with GC, we want to include it's impact
public class Pooling
{
[Params((int)1E+2, // 100 bytes
(int)1E+3, // 1 000 bytes = 1 KB
(int)1E+4, // 10 000 bytes = 10 KB
(int)1E+5, // 100 000 bytes = 100 KB
(int)1E+6, // 1 000 000 bytes = 1 MB
(int)1E+7)] // 10 000 000 bytes = 10 MB
public int SizeInBytes { get; set; }
private ArrayPool<byte> sizeAwarePool;
[GlobalSetup]
public void GlobalSetup()
=> sizeAwarePool = ArrayPool<byte>.Create(SizeInBytes + 1, 10); // let's create the pool that knows the real max size
[Benchmark]
public void Allocate()
=> DeadCodeEliminationHelper.KeepAliveWithoutBoxing(new byte[SizeInBytes]);
[Benchmark]
public void RentAndReturn_Shared()
{
var pool = ArrayPool<byte>.Shared;
byte[] array = pool.Rent(SizeInBytes);
pool.Return(array);
}
[Benchmark]
public void RentAndReturn_Aware()
{
var pool = sizeAwarePool;
byte[] array = pool.Rent(SizeInBytes);
pool.Return(array);
}
}
public class DontForceGcCollectionsConfig : ManualConfig
{
public DontForceGcCollectionsConfig()
{
Add(Job.Default
.With(new GcMode()
{
Force = false // tell BenchmarkDotNet not to force GC collections after every iteration
}));
}
}
結果
如果你對於BenchmarkDotNet在記憶體診斷程式開啟的情況下所輸出的內容不清楚的話,你可以讀我的這一篇文章來了解如何閱讀這些結果。
BenchmarkDotNet=v0.10.7, OS=Windows 10 Redstone 1 (10.0.14393)
Processor=Intel Core i7-6600U CPU 2.60GHz (Skylake), ProcessorCount=4
Frequency=2742189 Hz, Resolution=364.6722 ns, Timer=TSC
dotnet cli version=2.0.0-preview1-005977
[Host] : .NET Core 4.6.25302.01, 64bit RyuJIT
Job-EBWZVT : .NET Core 4.6.25302.01, 64bit RyuJIT
Method | SizeInBytes | Mean | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|
Allocate | 100 | 8.078 ns | 0.0610 | - | - | 128 B |
RentAndReturn_Shared | 100 | 44.219 ns | - | - | - | 0 B |
對於非常小的記憶體塊,預設分配器可以更快
Method | SizeInBytes | Mean | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|
Allocate | 1000 | 41.330 ns | 0.4880 | 0.0000 | - | 1024 B |
RentAndReturn_Shared | 1000 | 43.739 ns | - | - | - | 0 B |
對於1000個位元組他們的速度也差不多
Method | SizeInBytes | Mean | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|
Allocate | 10000 | 374.564 ns | 4.7847 | 0.0000 | - | 10024 B |
RentAndReturn_Shared | 10000 | 44.223 ns | - | - | - | 0 B |
隨著分配的位元組增加,被分配的記憶體增多導致程式越來越慢。
Method | SizeInBytes | Mean | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|
Allocate | 100000 | 3,637.110 ns | 31.2497 | 31.2497 | 31.2497 | 10024 B |
RentAndReturn_Shared | 100000 | 46.649 ns | - | - | - | 0 B |
第2代回收,當大於85000位元組時,我們看到了第一次的Full GC回收。
Method | SizeInBytes | Mean | StdDev | Gen 0/1/2 | Allocated | |
---|---|---|---|---|---|---|
RentAndReturn_Shared | 100 | 44.219 ns | 0.0314 ns | - | 0 B | |
RentAndReturn_Shared | 1000 | 43.739 ns | 0.0337 ns | - | 0 B | |
RentAndReturn_Shared | 10000 | 44.223 ns | 0.0333 ns | - | 0 B | |
RentAndReturn_Shared | 100000 | 46.649 ns | 0.0346 ns | - | 0 B | |
RentAndReturn_Shared | 1000000 | 42.423 ns | 0.0623 ns | - | 0 B |
此刻,你應該注意到了,ArrayPool
被分配的快取
如果當我們在給定的池中租賃的快取超過了最大長度限制(2^20,ArrayPool.Shared)會發生什麼呢?
Method | SizeInBytes | Mean | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|
Allocate | 10000000 | 557,963.968 ns | 211.5625 | 211.5625 | 211.5625 | 10000024 B |
RentAndReturn_Shared | 10000000 | 651,147.998 ns | 207.1484 | 207.1484 | 207.1484 | 10000024 B |
RentAndReturn_Aware | 10000000 | 47.033 ns | - | - | - | 0 B |
當超過了最大長度限制,每一次執行時都會重新分配一段新的快取區。並且當你把它還到池裡的時候,都會被忽略而不是再放入池中。
別擔心,ArrayPool
為了避免這種問題,你可以使用ArrayPool
MemoryStream的池化
有時,為了避免LOH的分配一個陣列可能不是很夠,有個第三方API的,
感謝Victor Baybekov我發現了Microsoft.IO.RecyclableMemoryStream庫,這個庫提供了MemoryStream物件的池化,這個是Bing的工程師為了解決LOH問題所涉及的。想要知道更多細節可以檢視Ben Watson寫的這篇部落格。
總結
- LOH = 第2代 = Full GC = 糟糕的效能
- ArrayPool 被設計為更好的效能
- 如果你能控制生命週期可以使用池化
- 預設使用ArrayPool
.Shared - 池化的時候分配的記憶體不要超過最大陣列長度限制
- 池越少,LOH就會越小,效率越好