一、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的初始化
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 單例。
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]請求的大小對於池來說太大了,分配一個完全符合所請求長度的數組。 當它返回到池中時,我們將直接扔掉它。
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的對象歸還
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中,我們允許從更大的桶中租用。
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 語句確保在退出臨界區時正確釋放鎖,以處理可能的線程中止。