C#中ArrayPool的底层原理

61 阅读4分钟
date: 2025-10-19 15:54
title: ArrayPool的底层原理
category: dotnet
tag:
- memory
description: 探究ArrayPool的底层原理

ArrayPool的简单使用

var pool = ArrayPool<byte>.Shared;
var bytes = pool.Rent(1024);
....
pool.Return(bytes);
  • 文中所研究的的 ArrayPool 为 .net 自带的 SharedArrayPool
  • 可以将ArrayPool看成是一个黑盒子,需要内存的时候去向黑盒子借一些,使用完毕之后再还给它,旨在减少向操作系统申请内存的次数,也减少了GC回收的次数。亦可以理解为缓存吧。

ArrayPool底层原理

  • ArrayPool中分配了 27个桶,每个桶都对应不同的尺寸,分别是 16、32、64、128、256...1G

    • 他们之间的关系是 x 2,最小的是 16个字节,最大的是 1024 * 1024 * 1024 也就是一个 G,如果超出了这个范围,那么就不会缓存。
  • 每次 Rent 的时候先看桶中有没有闲置的 Buffer ,如果有的话直接返回 Buffer,否则需要申请内存。需要注意的是,返回的数组大小 >= 申请的大小

  • Return 的时候将 Buffer 放到对应的桶中,方便下次借用的时候直接拿过来用。

  • notes:

    • bucket是ThreadLocal的,不能跨线程

    • 可以不在 Rent 的前提下直接Return,前提是长度有效。

      • 有效长度为: [2^4, 2^30] 且为2的倍数
    • 用完一定要记得 Return,否则与直接 new 无异(且有空间损耗)

简化过的源码


class SharedArrayPool<T> : ArrayPool<T>
{
    private const int NumBuckets = 27; 
    
    [ThreadStatic]
    private static SharedArrayPoolThreadLocalArray[]? t_tlsBuckets;
    
    public override T[] Rent(int minimumLength)
    {
        T[]? buffer;
        // calculate bucket index
        int bucketIndex = Utilities.SelectBucketIndex(minimumLength);
        
        SharedArrayPoolThreadLocalArray[]? tlsBuckets = t_tlsBuckets;
        
        if (tlsBuckets is not null && (uint)bucketIndex < (uint)tlsBuckets.Length)
        {
            buffer = Unsafe.As<T[]>(tlsBuckets[bucketIndex].Array);
            if (buffer is not null)
            {
                // get from the bucket
                tlsBuckets[bucketIndex].Array = null;
                return buffer;
            }
        }
        // resize the allocate size
        minimumLength = ...;
        // allocate memory
        buffer = GC.AllocateUninitializedArray<T>(minimumLength);
        return buffer;
    }
    
    public override void Return(T[] array, bool clearArray = false)
    {
        int bucketIndex = Utilities.SelectBucketIndex(array.Length);
        
        if ((uint)bucketIndex < (uint)tlsBuckets.Length)
        {
            // Check to see if the buffer is the correct size for this bucket.
            if (array.Length != Utilities.GetMaxSizeForBucket(bucketIndex))
            {
                throw new ArgumentException(SR.ArgumentException_BufferNotFromPool, nameof(array));
            }
            ref SharedArrayPoolThreadLocalArray tla = ref tlsBuckets[bucketIndex];
            Array? prev = tla.Array;
            // put the array into bucket
            tla = new SharedArrayPoolThreadLocalArray(array);
        }
    }
}

验证Return缓存

var pool = ArrayPool<byte>.Shared;
​
var bucketsField = pool.GetType().GetField("t_tlsBuckets", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
if (bucketsField == null) return;
var buckets = bucketsField.GetValue(pool);
​
var bytes = pool.Rent(100 * 1024 * 1024);
​
pool.Return(bytes);
  • 运行测试代码,在 Rent 行处打上断点,发现 buckets 27个均没有 Array,等 Return 之后再看 buckets 发现 23 处的bucketArray 已有实际内容。
  • image.png

验证缓存命中

long before = GC.GetAllocatedBytesForCurrentThread();
var buffer = ArrayPool<byte>.Shared.Rent(1024);
long after = GC.GetAllocatedBytesForCurrentThread();
var allocateSize = after - before;
if (allocateSize > 0)
    Debug.WriteLine("申请内存");
else
    Debug.WriteLine("命中缓存");
  • Rent 前后分别打印一下当前线程GC所分配的内存的大小,判定是 "申请内存" 还是 "命中缓存"

验证超过1GB的数组不会缓存

方法一:使用 GC.GetAllocatedBytesForCurrentThread() 方法

方法二:使用vs调试查看bucket array

  • Return 之后查看27个缓存中有没有超过 1GB 的数组,可以通过vs调试直接看

方法三:windbg查看 Return 执行过程

var pool = ArrayPool<byte>.Shared;
var bytes = pool.Rent(1025 * 1024 * 1024);
pool.Return(bytes);
  • image.png
  • 可以看到,将 1025mb 的数组 Return 时,bucketIndex 是27,已经超出了缓存buckets的长度,所以相当于Return 方法什么都没有做。

验证不Rent,直接Return一个数组是否会缓存

  • 看数组的大小,如果长度有效则缓存,如果长度无效则抛出异常

  • 如果长度为128,正好符合缓存bucket的大小,所以能够缓存,没有任何报错

    • image.png
  • 如果是129字节,那么在 Return 的时候会报错.

    • image.png
    • 查看源代码后发现是有一处大小校验,确保缓存的大小必须符合bucket 的大小规则([2^4, 2^30]且是2的整数倍)
      • image.png

验证缓存的ThreadLocal性

var pool = ArrayPool<byte>.Shared;
​
var bytes = new byte[128];
bytes[0] = 1;
bytes[1] = 2;
bytes[2] = 3;
​
pool.Return(bytes);
​
await Task.Run(() =>
{
    var pool2 = ArrayPool<byte>.Shared;
    
    var ret = pool2.Rent(128);
    Debug.WriteLine((ret == bytes).ToString());
});
  • 如上代码所示,先在主线程缓存了一个128长度的数组,再开了一个线程去Rent 128,你将会发现并没有命中缓存。

Q & A

  • ArrayPool最大支持多大的缓存?
  • var bytes = ArrayPool<byte>.Shared.Rent(65) 返回的bytes 长度为多少?
  • 如果先在线程A中缓存了一个128 长度的byte[],那么随后在线程B中Rent(128)能够命中缓存吗?
  • 如果光 Rent 而不 Return 会出现什么样的后果?

references

扩展

  • 如果在已经存在缓存的情况下,调用 Returnold buffer 哪去了?