深度解析C#數組對象池ArrayPool<T>底層原理

   提到池化技術,很多同學可能都不會感到陌生,因爲無論是在我們的項目中,還是在學習的過程的過程,都會接觸到池化技術。池化技術旨在提高資源的重複使用和系統性能,在.NET中包含以下幾種常用的池化技術。
    (1)、連接池(Connection Pool):用於管理數據庫連接的池化技術。連接池允許應用程序重複使用已經建立的數據庫連接,而不是在每次需要連接時都創建新的連接。
    (2)、線程池(Thread Pool):用於管理線程的池化技術。線程池可以重複使用已有的線程,避免頻繁創建和銷燬線程,從而提高系統的性能和資源利用率。
    (3)、對象池(Object Pool):用於管理對象的池化技術。對象池允許應用程序重複使用已經創建的對象,而不是在每次需要對象時都創建新的對象。這有助於減少垃圾回收的壓力,提高內存使用效率。
    (4)、連接池(Socket Pool):用於管理網絡套接字的池化技術。類似於連接池,網絡套接字池允許應用程序重複使用已經建立的套接字,提高網絡通信的效率。
    (5)、資源池(Resource Pool):泛指用於管理各種類型資源的池化技術。這可以包括文件句柄、圖形資源等。
  以上的這些池化技術,在.NET中使用的是以下的這些對象:
    (1)、MemoryPool:用於內存池化,允許你更有效地分配和管理內存,特別是對於大量小對象的情況。
    (2)、ArrayPool:用於管理數組類型的內存塊。它允許你重用數組,減少頻繁創建和銷燬數組的開銷。
    (3)、System.Buffers.MemoryManager:是一個抽象基類,允許你創建自定義的內存管理器。這可以用於創建適應特定場景的內存池。
    (4)、連接池 (Connection Pool):與數據庫相關的類(如SqlConnection、MySqlConnection等)通常具有連接池的內置實現。這允許應用程序重用數據庫連接,提高性能。
    (5)、線程池 (ThreadPool):ThreadPool 類提供了對線程池的訪問,允許應用程序將工作項提交給池中的線程執行,以減少線程的創建和銷燬開銷。
  這次我們先來介紹一下.NET的對象池化技術,對於C#中提供了的ArrayPool<T>,可能很多同學並不是特別的熟悉,尤其是其內部的實現原理和機制。在.NET以前的版本中是直接提供ObjectPool類型進行對象的複用。對象池技術的產生背景主要是在編程中,由於需要頻繁地分配和釋放內存,可能導致性能下降,特別是在高負載和大規模數據處理的情況下。
  ArrayPool<T>主要是用於管理和重複使用數組(或其他內存塊)的機制,目的是爲了減少垃圾回收的壓力,提高內存使用效率,並降低因爲頻繁分配和釋放內存而導致的性能開銷。有以下幾個具體的應用場景:
    (1)、性能優化:在某些應用中,特別是需要處理大量數據的高性能應用,頻繁地分配和釋放內存可能會導致垃圾回收的開銷。
    (2)、數組重用:當某個數組不再被使用時,它並不會立即被銷燬,而是放回到 ArrayPool 中,以備將來再次使用。
    (3)、減少內存碎片:頻繁地分配和釋放大塊內存可能導致內存碎片化,ArrayPool可以在一定程度上減少這種內存碎片化。
    (4)、多線程環境下的內存管理:多個線程同時嘗試分配和釋放內存,可能會導致競爭條件和性能問題。ArrayPool 通過使用線程安全的機制來管理內存。
  具體的應用場景也比較多,例如:網絡編程、圖形處理、數據庫操作、並行計算、流式處理、緩存管理等等實際的開發場景中都存在。接下來我們就來具體看看ArrayPool的內部實現機制和原理是怎麼樣的,是如何高效的進行對象的管理和內存分配。在C#中ArrayPool的底層默認實現是由ConfigurableArrayPool類型完成。

一、ArrayPool應用樣例

 1 using System;
 2 using System.Buffers;
 3 
 4 class ArrayPoolExample
 5 {
 6     static void Main()
 7     {
 8         // 創建數組池實例
 9         ArrayPool<int> arrayPool = ArrayPool<int>.Shared;
10 
11         // 請求租借一個大小爲 5 的數組
12         int[] rentedArray = arrayPool.Rent(5);
13 
14         try
15         {
16             // 使用租借的數組進行操作
17             for (int i = 0; i < rentedArray.Length; i++)
18             {
19                 rentedArray[i] = i * 2;
20             }
21         }
22         finally
23         {
24             // 使用完畢後歸還數組到數組池
25             arrayPool.Return(rentedArray);
26         }
27     }
28 }

  以上的樣例比較簡單,主要包含:創建數組池實例、租借一個大小爲 5 的數組、使用租借的數組進行操作、使用完畢後歸還數組到數組池。在實際的項目中,我們可以對ArrayPool進行包裝,創建我們需要的不同對象池的管理,這可以根據我們實際的項目需求進行開發。

  對於以上的幾步操作,我們可能會問,ArrayPool的初始化、數組對象的租借、數組對象歸還是如何實現的呢,並且爲什麼能夠做到對象的複用,以及如何實現內存使用較低的呢,那麼我們就帶着這幾個問題往下看看。

二、ArrayPool的初始化

  首先我們來看看ArrayPool的初始化,這是對應的實現代碼:
1         private static readonly SharedArrayPool<T> s_shared = new SharedArrayPool<T>();
2  
3         public static ArrayPool<T> Shared => s_shared;
4 
5         public static ArrayPool<T> Create() => new ConfigurableArrayPool<T>();

  從以上ArrayPool的初始化代碼可以發現,其數組對象池的創建是由ConfigurableArrayPool類完成的,那麼我們繼續看一下對應的初始化邏輯。部分代碼已經做過刪減,我們只關注核心的實現邏輯,需要看全部的實現代碼的同學,可以自行前往GitHub上查看。

 1         private const int DefaultMaxArrayLength = 1024 * 1024;
 2         private const int DefaultMaxNumberOfArraysPerBucket = 50;
 3         private readonly Bucket[] _buckets;
 4         internal ConfigurableArrayPool() : this(DefaultMaxArrayLength, DefaultMaxNumberOfArraysPerBucket){ }
 5         internal ConfigurableArrayPool(int maxArrayLength, int maxArraysPerBucket)
 6         {
 7             ...
 8             
 9             int maxBuckets = Utilities.SelectBucketIndex(maxArrayLength);            
10             var buckets = new Bucket[maxBuckets + 1];            
11             for (int i = 0; i < buckets.Length; i++)
12             {
13                 buckets[i] = new Bucket(Utilities.GetMaxSizeForBucket(i), maxArraysPerBucket, poolId);
14             }
15             _buckets = buckets;
16         }

  我們從源碼中可以看出幾個比較重要的實現邏輯,ConfigurableArrayPool在初始化時,設置了默認的兩個參數DefaultMaxArrayLength和DefaultMaxNumberOfArraysPerBucket,分別用於設置默認的池中每個數組的默認最大長度(2^20)和設置每個桶默認可出租的最大數組數。根據傳入的參數,對其調用Utilities.SelectBucketIndex(maxArrayLength)進行計算,根據最大數組長度計算出桶的數量 maxBuckets,然後創建一個數組 buckets。

1         internal static int SelectBucketIndex(int bufferSize)
2         {
3             return BitOperations.Log2((uint)bufferSize - 1 | 15) - 3;
4         }

  SelectBucketIndex使用位操作和數學運算來確定給定緩衝區大小應分配到哪個桶。該方法的目的是爲了根據緩衝區的大小,有效地將緩衝區分配到適當大小的桶中。在bufferSize大小介於 2^(n-1) + 1 和 2^n 之間時,分配大小爲 2^n 的緩衝區。使用了BitOperations.Log2 方法,計算 (bufferSize - 1) | 15 的二進制對數(以 2 爲底)。由於要處理1到16字節之間的緩衝區,使用了|15 來確保範圍內的所有值都會變成15。最後,通過-3進行調整,以滿足桶索引的需求。針對零大小的緩衝區,將其分配給最高的桶索引,以確保零長度的緩衝區不會由池保留。對於這些情況,池將返回 Array.Empty 單例。

  如果我們沒有調整默認值,那麼創建的maxBuckets=16,說明在默認情況下會創建17個桶。對於Utilities.GetMaxSizeForBucket(i)方法根據給定的桶索引,計算該桶所能容納的緩衝區的最大大小。通過左移操作符,可以快速計算出適應桶索引的緩衝區大小。
1         internal static int GetMaxSizeForBucket(int binIndex)
2         {
3             int maxSize = 16 << binIndex;
4             return maxSize;
5         }

  GetMaxSizeForBucket將數字 16 左移 binIndex 位。因爲左移是指數增長的,所以這樣的計算方式確保了每個桶的大小是前一個桶大小的兩倍。初始桶的索引(binIndex 爲 0)對應的最大大小爲 16。這種是比較通用的內存管理的策略,按照一系列固定的大小劃分內存空間,這樣可以減少分配的次數。接下來我們看一下Bucket對象的初始化代碼。

 1             internal readonly int _bufferLength;            
 2             private readonly T[]?[] _buffers;            
 3             private readonly int _poolId;
 4             private SpinLock _lock; 
 5             internal Bucket(int bufferLength, int numberOfBuffers, int poolId)
 6             {
 7                 _lock = new SpinLock(Debugger.IsAttached); 
 8                 _buffers = new T[numberOfBuffers][];
 9                 _bufferLength = bufferLength;                
10                 _poolId = poolId;
11             }

  SpinLock只有在附加調試器時才啓用線程跟蹤;它爲Enter/Exit增加了不小的開銷;numberOfBuffers表示可以租借的次數,只初始化定義個二維的泛型數組,未分配內存空間;bufferLength每個緩衝區的大小。以上的邏輯大家可能不是很直觀,我們用一個簡單的圖給大家展示一下。

 1 ArrayPool
 2   |
 3   +-- Bucket[0]  (Buffer Size: 16)
 4   |     +-- Buffer 1 (Size: 16)
 5   |     +-- Buffer 2 (Size: 16)
 6   |     +-- ...
 7   |
 8   +-- Bucket[1]  (Buffer Size: 32)
 9   |     +-- Buffer 1 (Size: 32)
10   |     +-- Buffer 2 (Size: 32)
11   |     +-- ...
12   |
13   ...
14   默認會創建50個Buffer

  如果對C#的字典的結構比較瞭解的同學,可能很好理解,ArrayPool是由一個一維數組和一個二維泛型數組進行構建。無論是.NET 還是JAVA中,很多的複雜的數據結構都是由多種簡單結構進行組合,這樣不僅一定程度上保證數據的取的效率,又可以考慮插入、刪除的性能,也兼顧內存的佔用情況。這裏用一個簡單的圖來說明一下二維數組的初始化時佔用的內存的結構。(_buffers = new T[numberOfBuffers][])

 1 +-----------+
 2 | arrayInt  |
 3 +-----------+
 4 |    [0]    | --> [ ] (Possibly null or an actual array)
 5 +-----------+
 6 |    [1]    | --> null
 7 +-----------+
 8 |    [2]    | --> null
 9 +-----------+
10 
11 +----------+
12 | arrInt1  |
13 +----------+
14 |          | --> [ ] (Possibly null or an actual array)
15 +----------+

三、ArrayPool的對象租借

  上面簡單的介紹了數組對象池的初始化,其實很多同學可以發現,在對象沒有進行租借時,整個對象池 並沒有佔用多少空間,因爲用於存儲對象的二維數組都只是進行了申明和設定了對應的大小。接下來我們來看看具體的租借實現邏輯。(部分代碼已做刪減,只關注核心邏輯)
 1         public override T[] Rent(int minimumLength)
 2         {
 3             if (minimumLength == 0){ return Array.Empty<T>(); }
 4             T[]? buffer;
 5             int index = Utilities.SelectBucketIndex(minimumLength);
 6             if (index < _buckets.Length)
 7             {
 8                 const int MaxBucketsToTry = 2;
 9                 int i = index;
10                 do
11                 {
12                     buffer = _buckets[i].Rent();
13                     if (buffer != null) { return buffer; }
14                 }
15                 while (++i < _buckets.Length && i != index + MaxBucketsToTry);
16                 buffer = new T[_buckets[index]._bufferLength];
17             }
18             else
19             {
20                 buffer = new T[minimumLength];
21             }
22             return buffer;
23         }

  從源碼中我們可以看到,如果請求的數組長度爲零,直接返回一個空數組。允許請求零長度數組,因爲它是一個有效的長度數組。因爲在這種情況下,池的大小沒有限制,不需要進行事件記錄,並且不會對池的狀態產生影響。根據傳入的minimumLength確定數組長度對應的池的桶的索引,在選定的桶中嘗試租用數組,如果找到可用的數組,記錄相應的事件並返回該數組。如果未找到可用的數組,會嘗試在相鄰的幾個桶中查找(MaxBucketsToTry=2)。buffer = newT[_buckets[index]._bufferLength]表示如果池已耗盡,則分配一個具有相應大小的新緩衝區到合適的桶。buffer = new T[minimumLength]請求的大小對於池來說太大了,分配一個完全符合所請求長度的數組。 當它返回到池中時,我們將直接扔掉它。

  接下來我們來具體看一下具體完成租借的操作方法_buckets[i].Rent()的實現邏輯。該方法從桶中租用一個緩衝區。它在桶中找到下一個可用的緩衝區,如果沒有可用的,則分配一個新的緩衝區。租用的緩衝區將被從桶中移除。如果啓用了事件記錄,將記錄緩衝區的租用事件。
 1             internal T[]? Rent()
 2             {
 3                 T[]?[] buffers = _buffers;
 4                 T[]? buffer = null;
 5                 bool lockTaken = false, allocateBuffer = false;
 6                 try
 7                 {
 8                     _lock.Enter(ref lockTaken);
 9                     if (_index < buffers.Length)
10                     {
11                         buffer = buffers[_index];
12                         buffers[_index++] = null;
13                         allocateBuffer = buffer == null;
14                     }
15                 }
16                 finally
17                 {
18                     if (lockTaken) _lock.Exit(false);
19                 }
20                 if (allocateBuffer)
21                 {
22                     buffer = new T[_bufferLength];
23                 }
24                 return buffer;
25             }

  我們來具體看一下這個方法的核心邏輯。T[]?[] buffers = _buffers通過獲取 _buffers 字段的引用,獲取桶中緩衝區數組的引用,並初始化一個用於保存租用的緩衝區的變量 buffer。使用 SpinLock 進入臨界區,在臨界區中,檢查 _index 是否小於緩衝區數組的長度buffers.Length。來判斷桶是否還有緩衝區可以使用。我們從if(allocateBuffer)可以看出,如果allocateBuffer==null時,則需要生成一個對應大小的緩衝區。可以明顯的看到,具體的緩衝區對象都是在第一次使用的時候生成的,未使用時並不初始化,不佔據內存空間。

四、ArrayPool的對象歸還

  上面我們介紹了對象的初始化和租借的實現邏輯,接下來我們來看一下對象的歸還是如何實現的。對於ArrayPool是怎麼實現對象的高效複用,重點也在對象的歸還策略上,正是因爲對象創建完畢之後,沒有直接銷燬掉,而是緩存在數組對象池中,所以下次纔可以進行復用。
  首先來看一下歸還的策略,Return該方法的目標是將數組返回到池中,並在必要時清空數組內容。在此過程中,記錄相應的事件,以便監測池的使用情況。
 1         public override void Return(T[] array, bool clearArray = false)
 2         {
 3             if (array.Length == 0) { return; }
 4             int bucket = Utilities.SelectBucketIndex(array.Length);
 5             bool haveBucket = bucket < _buckets.Length;
 6             if (haveBucket)
 7             {
 8                 if (clearArray) { Array.Clear(array); }
 9                 _buckets[bucket].Return(array);
10             }
11         }

  首先是對歸還的數組對象進行長度的判斷,如果傳入的數組長度爲零,表示是一個空數組,直接返回,不進行任何處理。在池中,對於長度爲零的數組,通常不會真正從池中取出,而是返回一個單例,以提高效率。然後根據數組的長度計算確定傳入數組的長度對應的桶的索引。bucket < _buckets.Lengt判斷是否存在與傳入數組長度對應的桶,如果存在,表示該數組的長度在池的有效範圍內。如果存在對應的桶,根據用戶傳入的 clearArray 參數,選擇是否清空數組內容,然後將數組返回給對應的桶。_buckets[bucket].Return(array)將緩衝區返回到它的bucket。將來,我們可能會考慮讓Return返回false不掉一個桶,在這種情況下,我們可以嘗試返回到一個較小大小的桶,就像在Rent中,我們允許從更大的桶中租用。

  接下來我們來具體看一下_buckets[bucket].Return(array)的實現邏輯。
 1             internal void Return(T[] array)
 2             {
 3                 if (array.Length != _bufferLength)
 4                 {
 5                     throw new ArgumentException(SR.ArgumentException_BufferNotFromPool, nameof(array));
 6                 }
 7                 bool returned;
 8                 bool lockTaken = false;
 9                 try
10                 {
11                     _lock.Enter(ref lockTaken);
12                     returned = _index != 0;
13                     if (returned) { _buffers[--_index] = array; }
14                 }
15                 finally
16                 {
17                     if (lockTaken) _lock.Exit(false);
18                 }
19             }

  這一部分的實現邏輯相對較簡單,首先判斷歸還的數組對象長度是否符合要求,在將緩衝區返回到桶之前,首先檢查傳入的緩衝區的長度是否與桶的期望長度相匹配。 如果長度不匹配,拋出 ArgumentException,表示傳入的緩衝區不是從該池中租用的。使用 SpinLock 進入臨界區。在臨界區中,檢查是否有可用的空槽,如果有,則將傳入的緩衝區放入下一個可用槽,並將 _index 減小。如果沒有可用槽,則不存儲緩衝區。使用 try/finally 語句確保在退出臨界區時正確釋放鎖,以處理可能的線程中止。

五、ArrayPool的應用建議

  上面介紹了ArrayPool的產生的背景和用途,也重點介紹了ArrayPool的實現原理和機制,哪些我們在具體的項目中應用是,需要注意的點有哪些呢,這裏簡單的總結了幾點:
    1、適當選擇數組大小:在請求數組時,儘量選擇適當大小的數組。不要過度請求超過實際需求的大數組,因爲這可能會浪費內存。在選擇數組大小時,可以考慮實際數據量以及性能方面的需求。
    2、及時釋放數組:當你不再需要數組時,記得及時釋放它。雖然 ArrayPool 會負責管理這些數組,但在不再使用時顯式地調用 Return 方法可以更快地將數組返回到池中,以便其他部分的代碼可以重用它。
    3、小心數組的生命週期:當你將數組返回到池中後,不應該再嘗試使用它。ArrayPool 可能已經將其分配給其他部分的代碼。嘗試使用已經返回到池中的數組可能導致不可預測的行爲。
    4、考慮線程安全性:如果你的應用程序是多線程的,確保在多個線程之間正確使用 ArrayPool。ArrayPool 提供了線程安全的方法,但在多線程環境中,仍然需要小心協調數組的分配和釋放。
    5、調整默認值(如果有必要):ArrayPool 提供了默認值,但這些值可能不適用於所有情況。根據應用程序的特定需求,可能需要調整默認值,例如,通過調整 DefaultMaxArrayLength 和 DefaultMaxNumberOfArraysPerBucket。
    6、測量和分析:在使用 ArrayPool 之後,測量和分析應用程序的性能。檢查內存使用情況、垃圾回收頻率等方面,確保 ArrayPool 的使用對性能有積極的影響。
    7、合理權衡:在使用 ArrayPool 時,要平衡性能和內存利用效率。不要過度優化,而導致代碼變得複雜難以維護,同時也不要犧牲性能。
  以上是在實際應用中的幾點小建議,一種技術的產生有氣特定的意義,但也不是能夠解決所有的問題,往往是在解決一個問題時,會造成其他問題的產生,我們在實際的解決過程中,需要分析當前問題中最需要解決的點是什麼,這就要分析問題中的背景和原因,最後再選擇合適的方法進行處理。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章