深度解析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 时,要平衡性能和内存利用效率。不要过度优化,而导致代码变得复杂难以维护,同时也不要牺牲性能。
  以上是在实际应用中的几点小建议,一种技术的产生有气特定的意义,但也不是能够解决所有的问题,往往是在解决一个问题时,会造成其他问题的产生,我们在实际的解决过程中,需要分析当前问题中最需要解决的点是什么,这就要分析问题中的背景和原因,最后再选择合适的方法进行处理。