.NET效能優化-推薦使用Collections.Pooled

2022-05-25 12:03:44

簡介

效能優化就是如何在保證處理相同數量的請求情況下佔用更少的資源,而這個資源一般就是CPU或者記憶體,當然還有作業系統IO控制程式碼、網路流量、磁碟佔用等等。但是絕大多數時候,我們就是在降低CPU和記憶體的佔用率。
之前分享的內容都有一些侷限性,很難直接改造,今天要和大家分享一個簡單的方法,只需要替換幾個集合型別,就可以達到提升效能和降低記憶體佔用的效果。
今天要給大家分享一個類庫,這個類庫叫Collections.Pooled,從名字就可以看出來,它是通過池化記憶體來達到降低記憶體佔用和GC的目的,後面我們會直接來看看它的效能到底怎麼樣,另外也會帶大家看看原始碼,為什麼它會帶來這些效能提升。

Collections.Pooled

專案連結:https://github.com/jtmueller/Collections.Pooled
該庫基於System.Collections.Generic中的類,這些類已經被修改,以利用新的System.Span<T>System.Buffers.ArrayPool<T>類庫,達到減少記憶體分配,提高效能,並允許與現代API的更大的互操作性的目的。
Collections.Pooled支援.NETStandard2.0(.NET Framework 4.6.1+),以及針對.NET Core 2.1+的優化構建。一套廣泛的單元測試和基準已經從corefx移植過來。

測試總數:27501。通過:27501。失敗:0。跳過:0。
測試執行成功。
測試執行時間:9.9019秒

如何使用

通過Nuget就可以很簡單的安裝這個類庫,NuGet Version

Install-Package Collections.Pooled
dotnet add package Collections.Pooled
paket add Collections.Pooled

Collections.Pooled類庫中,它針對我們常使用的集合型別都實現了池化的版本,和.NET原生型別的對比如下所示。

.NET原生 Collections.Pooled 備註
List<T> PooledList<T> 泛型集合類
Dictionary<TKey, TValue> PooledDictionary<TKey, TValue> 泛型字典類
HashSet<T> PooledSet<T> 泛型雜湊集合類
Stack<T> Stack<T> 泛型棧
Queue<T> PooledQueue<T> 泛型佇列

在使用時,我們只需要將對應的.NET原生版本換成Collections.Pooled版本就可以了,如下方的程式碼所示:

using Collections.Pooled;

// 使用方式是一樣的
var list = new List<int>();
var pooledList = new PooledList<int>();

var dictionary = new Dictionary<int,int>();
var pooledDictionary = new PooledDictionary<int,int>();

// 包括PooledSet、PooledQueue、PooledStack的使用方法都是一樣的

var pooledList1 = Enumerable.Range(0,100).ToPooledList();
var pooledDictionary1 = Enumerable.Range(0,100).ToPooledDictionary(i => i, i => i);

但是我們需要注意,Pooled型別實現了IDispose介面,它通過Dispose()方法將使用的記憶體歸還到池中,所以我們需要在使用完Pooled集合物件以後呼叫它的Dispose()方法。或者可以直接使用using var關鍵字。

using Collections.Pooled;

// 使用using var 會在pooled物件使用完畢後自動釋放
using var pooledList = new PooledList<int>();
Console.WriteLine(pooledList.Count);

// 使用using作用域 作用域結束以後就會釋放
using (var pooledDictionary = new PooledDictionary<int, int>())
{
	Console.WriteLine(pooledDictionary.Count);
}

// 手動呼叫Dispose方法
var pooledStack = new PooledStack<int>();
Console.WriteLine(pooledStack.Count);
pooledList.Dispose();

注意:使用Collections.Pooled內的集合物件最好需要釋放掉它,不過不釋放也沒有關係,GC最終會回收它,只是它不能歸還到池中,達不到節省記憶體的效果了。
由於它會複用記憶體空間,在將記憶體空間返回到池中的時候,需要對集合內的元素做處理,它提供了一個叫ClearMode的列舉供使用,定義如下:

namespace Collections.Pooled
{
    /// <summary>
    /// 這個列舉允許控制在內部陣列返回到ArrayPool時如何處理資料。
    /// 陣列返回到ArrayPool時如何處理資料。在使用預設選項之外的其他選項之前,請注意瞭解 
    /// 在使用預設值Auto之外的任何其他選項之前,請仔細瞭解每個選項的作用。
    /// </summary>
    public enum ClearMode
    {
        /// <summary>
        /// <para><code>Auto</code>根據目標框架有不同的行為</para>
        /// <para>.NET Core 2.1: 參照型別和包含參照型別的值型別在內部陣列返回池時被清除。 不包含參照型別的值型別在返回池時不會被清除。</para>
        /// <para>.NET Standard 2.0: 在返回池之前清除所有使用者型別,以防它們包含參照型別。 對於 .NET Standard,Auto 和 Always 具有相同的行為。</para>
        /// </summary>
        Auto = 0,
        
        /// <summary>
        /// The <para><code>Always</code> 設定的效果是在返回池之前總是清除使用者型別。
        /// </summary>
        Always = 1,
        
        /// <summary>
        /// <para><code>Never</code> 將導致池化集合在將它們返回池之前永遠不會清除使用者型別。</para>
        /// </summary>
        Never = 2
    }
}

預設情況下,使用預設值Auto即可,如果有特殊的效能要求,知曉風險後可以使用Never。
對於參照型別和包含參照型別的值型別,我們必須在將記憶體空間歸還到池的時候清空陣列參照,如果不清除會導致GC無法釋放這部分記憶體空間(因為元素的參照一直被池持有),如果是純值型別,那麼就可以不清空,在使用結構體替代類這篇文章中,我描述了參照型別和結構體(值型別)陣列的儲存區別,純值型別沒有物件頭回收也無需GC介入。

效能對比

我沒有單獨做Benchmark,直接使用的開源專案的跑分結果,很多專案的記憶體佔用都是0,那是因為使用的池化的記憶體,沒有多餘的分配

PooledList<T>

在Benchmark中迴圈向集合新增2048個元素,.NET原生的List<T>需要110us(根據實際跑分結果,圖中的毫秒應該是筆誤)和263KB記憶體,而PooledList<T>只需要36us0KB記憶體。

PooledDictionary<TKey, TValue>

在Benchmark中迴圈向字典新增10_0000個元素,.NET原生的Dictionary<TKey, TValue>需要11ms13MB記憶體,而PooledDictionary<TKey, TValue>只需要7ms0MB記憶體。

PooledSet<T>

在Benchmark中迴圈向雜湊集合新增10_0000個元素,.NET原生的HashSet<T>需要5348ms2MB,而PooledSet<T>只需要4723ms0MB記憶體。

PooledStack<T>

在Benchmark中迴圈向棧新增10_0000個元素,.NET原生的PooledStack<T>需要1079ms2MB,而PooledStack<T>只需要633ms0MB記憶體。

PooledQueue<T>

在Benchmark中迴圈向佇列新增10_0000個元素,.NET原生的PooledQueue<T>需要681ms1MB,而PooledQueue<T>只需要408ms0MB記憶體。

未手動釋放場景

另外在上文中我們提到了Pooled的集合型別需要釋放,但是不釋放也沒有太大的關係,因為GC會去回收。

private static readonly string[] List = Enumerable  
    .Range(0, 10000).Select(c => c.ToString()).ToArray();  
// 使用預設的集合型別  
[Benchmark(Baseline = true)]  
public int UseList()  
{  
    var list = new List<string>(1024);  
    for (var index = 0; index < List.Length; index++)  
    {  
        var item = List[index];  
        list.Add(item);  
    }  
    return list.Count;  
}  
// 使用PooledList 並且及時釋放  
[Benchmark]  
public int UsePooled()  
{  
    using var list = new PooledList<string>(1024);  
    for (var index = 0; index < List.Length; index++)  
    {  
        var item = List[index];  
        list.Add(item);  
    }  
    return list.Count;  
}  
// 使用PooledList 不釋放  
[Benchmark]  
public int UsePooledWithOutUsing()  
{  
    var list = new PooledList<string>(1024);  
    for (var index = 0; index < List.Length; index++)  
    {  
        var item = List[index];  
        list.Add(item);  
    }  
    return list.Count;  
}

Benchmark結果如下:

可以從上面的Benchmark結果可以得出結論。

  • 及時釋放Pooled型別集合幾乎不會觸發GC和分配記憶體,從上圖中它只分配了56Byte記憶體。
  • 就算不釋放Pooled型別集合,因為它從池中分配記憶體,在進行ReSize擴容操作時還是會複用記憶體,另外跳過了GC分配記憶體初始化步驟,速度也比較快。
  • 最慢的就是使用普通集合型別,每次ReSize擴容操作都需要申請新的記憶體空間,GC也要回收之前的記憶體空間。

原理解析

如果大家看過我之前的博文你應該為集合型別設定初始大小淺析C# Dictionary實現原理就可以知道,.NET BCL開發人員為了高效能的隨機存取,這些基本集合型別的底層資料結構都是陣列,我們以List<T>為例。

  • 建立新的陣列來儲存新增進來的元素。
  • 如果陣列空間不夠,那麼就觸發擴容操作,申請2倍的空間大小。
    建構函式程式碼如下,可以看到是直接建立的泛型陣列:
public List(int capacity)
{
      if (capacity < 0)
          ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.capacity, ExceptionResource.ArgumentOutOfRange_NeedNonNegNum);

      if (capacity == 0)
          _items = s_emptyArray;
      else
          _items = new T[capacity];
}

那麼如果想要池化記憶體,只需要把類庫中使用new關鍵字申請的地方,改為使用池化的申請。這裡和大家分享.NET BCL中的一個型別,叫ArrayPool,它提供了可重複使用的泛型範例的陣列資源池,使用它可以降低對GC的壓力,在頻繁建立和銷燬陣列的情況下提升效能。
而我們Pooled型別的底層就是使用ArrayPool來共用資源池,從它的建構函式中,我們可以看到它預設使用的是ArrayPool<T>.Shared來分配陣列物件,當然你也可以建立自己的ArrayPool來讓它使用。

// 預設使用ArrayPool<T>.Shared池
public PooledList(int capacity, ClearMode clearMode, bool sizeToCapacity) : this(capacity, clearMode, ArrayPool<T>.Shared, sizeToCapacity) { }  

// 分配陣列使用 ArrayPool
public PooledList(int capacity, ClearMode clearMode, ArrayPool<T> customPool, bool sizeToCapacity)
{
    if (capacity < 0)
        ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.capacity, ExceptionResource.ArgumentOutOfRange_NeedNonNegNum);
    _pool = customPool ?? ArrayPool<T>.Shared;
    _clearOnFree = ShouldClear(clearMode);
    if (capacity == 0)
    {
        _items = s_emptyArray;
    }
    else
    {
        _items = _pool.Rent(capacity);
    }
    
    if (sizeToCapacity)
    {
        _size = capacity;
        if (clearMode != ClearMode.Never)
        {
            Array.Clear(_items, 0, _size);
        }
    }
 }

另外在進行容量調整操作(擴容)時,會將舊的陣列歸還回執行緒池,新的陣列也在池中獲取。

 public int Capacity
{
    get => _items.Length;
    set
    {
        if (value < _size)
        {
            ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.value, ExceptionResource.ArgumentOutOfRange_SmallCapacity);
        }

        if (value != _items.Length)
        {
            if (value > 0)
            {
                // 從池中分配陣列
                var newItems = _pool.Rent(value);
                if (_size > 0)
                {
                    Array.Copy(_items, newItems, _size);
                }
                // 舊陣列歸還到池中
                ReturnArray();
                _items = newItems;
            }
            else
            {
                ReturnArray();
                _size = 0;
            }
        }
    }
}
private void ReturnArray()  
{  
    if (_items.Length == 0)  
        return;  
    try  
    {  
        // 歸還到池中
        _pool.Return(_items, clearArray: _clearOnFree);  
    }  
    catch (ArgumentException)  
    {  
        // ArrayPool可能會丟擲異常,我們直接吞掉 
    }  
    _items = s_emptyArray;  
}

另外作者使用了Span優化了AddInsert等等API,讓它們有更好的隨機存取效能;另外還加入了TryXXX系列API,可以更方便的方式的使用它。比如List<T>類相比PooledList<T>就有多達170個修改。

總結

在我們線上實際的使用過程中,完全可以用Pooled提供的集合型別替代原生的集合型別,對降低記憶體佔用率和P95延時有非常大的幫助。
另外就算忘記釋放了,那效能也不會比使用原生的集合型別差多少。當然最好的習慣就是及時的釋放它。