簡介
性能優化就是如何在保證處理相同數量的請求情況下占用更少的資源,而這個資源一般就是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>
只需要36us和0KB內存。
PooledDictionary<TKey, TValue>
在Benchmark中循環向字典添加10_0000個元素,.NET原生的Dictionary<TKey, TValue>
需要11ms和13MB內存,而PooledDictionary<TKey, TValue>
只需要7ms和0MB內存。
PooledSet<T>
在Benchmark中循環向哈希集合添加10_0000個元素,.NET原生的HashSet<T>
需要5348ms和2MB,而PooledSet<T>
只需要4723ms和0MB內存。
PooledStack<T>
在Benchmark中循環向棧添加10_0000個元素,.NET原生的PooledStack<T>
需要1079ms和2MB,而PooledStack<T>
只需要633ms和0MB內存。
PooledQueue<T>
在Benchmark中循環向隊列添加10_0000個元素,.NET原生的PooledQueue<T>
需要681ms和1MB,而PooledQueue<T>
只需要408ms和0MB內存。
未手動釋放場景
另外在上文中我們提到了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優化了Add
、Insert
等等API,讓它們有更好的隨機訪問性能;另外還加入了TryXXX
系列API,可以更方便的方式的使用它。比如List<T>
類相比PooledList<T>
就有多達170個修改。
總結
在我們線上實際的使用過程中,完全可以用Pooled
提供的集合類型替代原生的集合類型,對降低內存占用率和P95延時有非常大的幫助。
另外就算忘記釋放了,那性能也不會比使用原生的集合類型差多少。當然最好的習慣就是及時的釋放它。