使用ArrayPool池化大型數組(翻譯)


原文鏈接: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 是托管數組的高性能池。您可以在 System.Buffers包中找到它,它的源代碼你也可以在 GitHub上找到。它的運用已經相當成熟並可以在生產中使用。它是面向.NET Stadard 1.1,這意味着您不僅可以在.NET Core應用程序中使用它,還可以在現有的.NET 4.5.1應用程序中使用它!

代碼示例

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

如何使用

首先你需要一個初始化的池,至少有三種方式可以獲得:

  1. 最建議的方式:使用 ArrayPool .Shared 屬性,它將返回一個線程安全的可共享的池對象實例,不過要記住他有一個默認的最大數組長度( 2^20 (1024*1024 = 1 048 576))。
  2. 使用 ArrayPool .Create靜態方法,也可以創建一個線程安全的池,並且可以自定義maxArrayLength和maxArraysPerBucket兩個參數,如果最大數組長度對你來說不夠的話,你可以嘗試使用。不過請記住,一旦你創建了它,你有責任讓它保持活力。
  3. 從抽象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操作符分配數組和使用ArrayPool 池化它們的性能消耗,為了確保基准測試包含GC的時間,我配置了BenchmarkDotNet不要進行GC回收。池化的性能測試包含了 RentReturn的消耗,我正在運行.NET Core 2.0的基准測試,這很重要,因為它具有更快的ArrayPool 版本。對於.NET Core 2.0,ArrayPool 是clr的一部分,而之前的框架使用corefx版本。兩個版本都非常快,它們的比較和它們的設計分析可能需要一篇單獨的博客文章來介紹了。

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 有針對於事件監測(ETW)的事件提供者,所以你可以使用PerfView或者其他工具來檢測你的應用程序並且監控BufferAllocated事件。

為了避免這種問題,你可以使用ArrayPool .Create方法,這個方法提供了maxArrayLength參數用來創建一個池。但是也不要創建太多的自定義池,使用池化的目標是為了保持LOH區域盡可能的小。如果你創建了太多的池,就會增爆LOH塊,充滿了大數組是無法被GC正常回收的(因為在你使用了自定義池之后使得這些大數組變為根引用)。這就是為什么很多流行的框架,例如ASP.NET CORE或者ImageSharp都只使用了ArrayPool .Shared,如果你只是使用ArrayPool .Shared而不是使用new實例化類的話,那么在悲觀情況下(請求的數組大小大於默認最大值),會看出來效率略微較之前下降(你會做額外的檢查並且重新分配)。但是在樂觀情況下,會很明顯比之前快很多,因為你只要從池里租賃就可以了。所以我相信默認使用ArrayPool .Shared就好了。如果BufferAllocated事件被頻繁調用,就可以考慮使用ArrayPool .Create方法。

MemoryStream的池化

有時,為了避免LOH的分配一個數組可能不是很夠,有個第三方API的,
感謝Victor Baybekov我發現了Microsoft.IO.RecyclableMemoryStream庫,這個庫提供了MemoryStream對象的池化,這個是Bing的工程師為了解決LOH問題所涉及的。想要知道更多細節可以查看Ben Watson寫的這篇博客

總結

  • LOH = 第2代 = Full GC = 糟糕的性能
  • ArrayPool 被設計為更好的性能
  • 如果你能控制生命周期可以使用池化
  • 默認使用ArrayPool .Shared
  • 池化的時候分配的內存不要超過最大數組長度限制
  • 池越少,LOH就會越小,效率越好

轉載請注明出處:https://www.cnblogs.com/briswhite/p/11349429.html


免責聲明!

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



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