深度解析C#数组对象池ArrayPool<T>底层原理
这次我们先来介绍一下.NET的对象池化技术,对于C#中提供了的ArrayPool<T>,可能很多同学并不是特别的熟悉,尤其是其内部的实现原理和机制。在.NET以前的版本中是直接提供ObjectPool类型进行对象的复用。对象池技术的产生背景主要是在编程中,由于需要频繁地分配和释放内存,可能导致性能下降,特别是在高负载和大规模数据处理的情况下。
一、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 语句确保在退出临界区时正确释放锁,以处理可能的线程中止。
五、ArrayPool的应用建议
深度解析C#数组对象池ArrayPool<T>底层原理的更多相关文章
- 深度解析PHP数组函数array_combine
前些天写了一篇关于array_merge的函数解析. 今天来看一个新的函数array_combine() 此函数一共有两个参数,一个是合并后数组的键名,另一个为键值. 注意:合并后数组的键名放在第一个 ...
- 深度解析PHP数组函数array_slice
看到array_slice()这个函数让我想起了VFP中的range这个范围取值的子句 这个函数一共有四个参数: 被取值的数组(必需) 取值的起始位置(必需) 取值的终止位置,如果不填写默认到数组最后 ...
- 深度解析PHP数组函数array_chunk
array_chunk是PHP中的一个数组分割函数,是将一个数组分割为多个数组块 我们可以把它理解卖豆腐的商人把一整块大豆腐切割为一个一个的小块来进行售卖 这个函数需要三个参数: 被切割的数组(必需) ...
- 深度解析PHP数组函数array_merge
很久之前就用到过这个函数,只不不过是简单的用用而已并没有做太深入的研究 今天在翻阅别人博客时看到了对array_merge的一些使用心得,故此自己来进行一次总结. array_merge是将一个或者多 ...
- Android如何解析json数组对象
import org.json.JSONArray; import org.json.JSONObject; //jsonData的数据格式:[{ "id": "27Jp ...
- Netty源码解析 -- 对象池Recycler实现原理
由于在Java中创建一个实例的消耗不小,很多框架为了提高性能都使用对象池,Netty也不例外. 本文主要分析Netty对象池Recycler的实现原理. 源码分析基于Netty 4.1.52 缓存对象 ...
- Unity性能优化-对象池
1.对象池Object Pool的原理: 有些GameObject是在游戏中需要频繁生成并销毁的(比如射击游戏中的子弹),以前的常规做法是:Instantiate不断生成预设件Prefab,然后采用碰 ...
- 并发编程(十二)—— Java 线程池 实现原理与源码深度解析 之 submit 方法 (二)
在上一篇<并发编程(十一)—— Java 线程池 实现原理与源码深度解析(一)>中提到了线程池ThreadPoolExecutor的原理以及它的execute方法.这篇文章是接着上一篇文章 ...
- jquery解析php通过ajax传过来的json二维数组对象
ajax获得php传过来的json二维数组对象,jquery解析 php代码: <?php $news = array( '武汉'=>array(1,2,3), '广州'=>arra ...
- javascript中对数组对象的深度拷贝
在前端开发的某些逻辑中,经常需要对现有的js对象创建副本,避免污染原始数据的情况. 如果是简单的一维数组对象,可以使用两个原生方法: 1.splice var arr1 = ['a', 'b', 'c ...
随机推荐
- 如何快速准备高质量的AI数据?
摘要:随着AI的快速发展,如何快速准备大量高质量的数据已经成为AI开发过程中一个极具挑战性的问题! 本文分享自华为云社区<如何快速准备高质量的AI数据?>,原文作者:徐波. 一.背景 通常 ...
- 火山引擎DataLeap的Catalog系统搜索实践 (二):整体架构
整体架构 火山引擎DataLeap的Catalog搜索系统使用了开源的搜索引擎Elasticsearch进行基础的文档检索(Recall阶段),因此各种资产元数据会被存放到Elasticsearch中 ...
- Solon Web 开发:四、认识请求上下文(Context)
Handler + Context 架构,是Solon Web 的基础.在 Context (org.noear.solon.core.handle.Context)里可以获取: 请求相关的对象与接口 ...
- Windows 清理C盘空间,将桌面,文档等移D盘
一般用户数据文件,缓存文件等,会默认放在C盘.而且有些程序必须装在C盘,久而久之,C盘空间越来越小,到后面没办法再安装使用一些程序. 可以将一些常用的移到D盘:特别是微信,动不动就几十个G的空间被占用 ...
- python 读取数据调翻译更新表字段
import time import requests import pymysql import datetime import random from hashlib import md5 imp ...
- 压测工具 Locust
一.认识Locust 定义 Locust是一款易于使用的分布式负载测试工具,完全基于事件,即一个locust节点也可以在一个进程中支持数千并发用户,不使用回调,通过gevent使用轻量级过程(即在自己 ...
- auth认证模块 auth_user表扩展
目录 auth认证模块前戏 django后台管理功能 创建超级管理员 auth认证相关模块及操作 用户注册 用户登录 网站首页效果 校验用户登录的装饰器 用户修改密码 用户注销登录 auth_user ...
- 详解 SSL(二):SSL 证书对网站的好处
在如今谷歌.百度等互联网巨头强制性要求网站 HTTPS 化的情况下, 网站部署 SSL 证书已然成为互联网的发展趋势.而在上一篇< 详解 SSL(一):网址栏的小绿锁有什么意义?>中,我们 ...
- # github.com/coreos/etcd/clientv3/balancer/resolver/endpoint
linux使用go连接etcd集群时报错: # github.com/coreos/etcd/clientv3/balancer/resolver/endpoint /root/go/pkg/mod/ ...
- Go--较复杂的结构类型
一.List List是一种有序的集合,可以包含任意数量的元素.与数组相比,list的长度可以动态调整,可以随时添加或删除元素,类似于切片 在go中,List是一个双向链表的实现. 实例 packag ...