NET性能优化-推荐使用Collections.Pooled(补充)

在上一篇.NET性能优化-推荐使用Collections.Pooled一文中,提到了使用Pooled类型的各种好处,但是在群里也有小伙伴讨论了很多,提出了很多使用上的疑问。
所以特此写了这篇文章,补充回答小伙伴们问到的一些问题,和遇到某些场景如何处理。

问题分析

以下就是这两天收集到比较常见的问题,我都收集到一起,统一给大家回复一下。

ArrayPool会不会无限扩大?

遇到的第一个问题就是我们Pooled类型依赖于ArrayPool进行底层数组的分配,那么我们一直使用Pooled类型会不会导致ArrayPool无限制的扩大下去?
回答:不会无限制的扩大,ArrayPool在.NET BCL库中有两种实现:

  • 一种是调用ArrayPool<T>.Shared的使用Thread-local storage方式实现的池,名称叫做TlsOverPerCoreLockedStacksArrayPool,这种池的话通过核心隔离使得在并发情况下的性能非常好,如果后面又时间我会出一篇源码解析的文章,它里面会限制池中对象最大的数量。

  • 第二种是调用ArrayPool<T>.Create()方法创建的池,这种会单独使用另一个类,叫ConfigurableArrayPool,可以在池对象创建的时候就指定构造函数的参数,达到限制大小的目的。

Dispose会不会影响性能?

另外有小伙伴比较关注的问题就是,对于Pooled里面提供的类型都实现了IDisposable接口,那么频繁的Dispose会不会影响性能呢?
回答: 先说结论,结论就是对性能的负面影响微乎其微。我们从两个方面来回答一下这个问题:

  • 其实实现IDisposable接口没有什么特殊的,就是要求类中需要有一个Dispose方法而已,和你自定义一个IFoo接口,整一个Foo类来实现这个接口一个意思。但是你需要注意实现了析构方法的场景,这个场景GC会将实例加入终结器队列,但是Pooled提供的类库中都没有实现析构方法,不存在这个问题。

  • 第二点就是如果要归还池化的对象,那么你需要手动调用Dispose方法或者使用using在作用域中自动调用Dispose方法,大家都知道调用一个方法会有性能开销,这个是肯定的。但是比起重新申请内存时GC需要初始化内存和回收内存来说,这些开销微乎其微。

可以将所有对象都池化吗?

既然池化的效果这么好,那么我可以将所有的对象都池化吗?这样是不是就没有了GC开销,更能节省性能呢?
回答: 可以将所有对象都池化,必要性不大。
因为我们对于池化能够提升性能是建立在对象创建的开销比重用大的这一个前提下,但是这就需要分场景讨论了,比如我们创建一个很大的数组,对于数组来说GC需要申请内存空间,初始化内存和回收内存,开销远远比重用大,所以此时池化对象是有很大的正面收益的;但是反观另外一个场景,就是一个只包含了几个字段的类,GC能很快的创建和回收它,这样可能收益会变小,甚至成为负优化。
最后还有一个问题就是,申请和归还到池中的时候,是需要线程安全的操作,因为同时间可能有很多线程在申请和归还对象,这时就会引入一些线程同步的问题,就算使用一些无锁算法,在高并发的情况下实际测试表现也不如GC来的快。

我这个场景如何使用Pooled?

1.创建的集合对象作用域不在一个方法内,应该怎么办? 样例代码如下所示:

 
// BLL层逻辑int Get(){    var pooledList = Get1();    // 省略代码逻辑    return pooledList.Sum();}// DAL层逻辑// 数据集合在其它方法创建PooledList<int> Get1(){    // 省略代码逻辑    // 创建的Pooled数组没办法在这个方法里面回收    return pooledList;}

这种其实很简单,只要在BLL层的Get方法中释放或者调用Dispose方法就可以了。也就是说在它最后被使用到的地方释放,而不是声明它的地方。

// BLL层逻辑int Get(){    // 在这个方法里面用using var释放就可以了      using var pooledList = Get1();    // 省略代码逻辑    return pooledList.Sum();}

2.底层接口返回的是一个IList<T>怎么办? 代码如下所示:​​​​​​​

// BLL层逻辑int Get(){    // 报错    // 底层返回的是 IList没有实现IDispose方法,    using var pooledList = Get1();    // 省略代码逻辑    return pooledList.Sum();}// DAL层逻辑// 虽然实际类型是PooledList,但是接口契约是IListIList<int> Get1(){    // 省略代码逻辑    return pooledList;}

这种的话确实改造起来麻烦一点,如果不想改变接口契约,那就普通方式的话在Get方法中加判断逻辑了,另外也可以用后面提到的Dispose.Scope类库。​​​​​​​

// BLL层逻辑int Get(){    var pooledList = Get1();    // 加一个转换    using var _ = (pooledList as IDisposable)    // 省略代码逻辑    return pooledList.Sum();}

3.在AspNetCore的ApiController的Action能用Pooled类吗?怎么回收Pooled类呢? 代码如下所示:​​​​​​​

[ApiController]public TestController : Controller{    [HttPost]    PooledList<int> GetSome() => BLL.GetSome();}

像这种情况比较常见,因为Action的返回值,AspNetCore框架还需要帮我们使用比如jsonxml等格式序列化,我们不好去修改框架的行为,给它人为加一个using
像这种情况,其实微软早就想到,可以让AspNetCore替我们去释放,在HttpContext.Response中有一个RegisterForDispose方法注册需要Dispose对象,它会在当前Http请求结束时会调用这个方法,用来释放对象。​​​​​​​

[ApiController]public TestController : Controller{    [HttPost]    PooledList<int> GetSome()     {        var list = BLL.GetSome();        // 注册Dispose, 将在Http请求结束时会帮我们释放list        HttpContext.Response.RegisterForDispose(list);        return list;    }}

当然也可以用后面要介绍的Dispose.Scope项目。

介绍Dispose.Scope项目

Dispose.Scope是一个可以让你方便的使用作用域管理实现了IDisposable接口的对象实例的类库。它的实现方式和代码都很简单。将需要释放的IDisposable注册到作用域中,然后在作用域结束时自动释放所有注册的对象。
Github地址:https://github.com/InCerryGit/Dispose.Scope
NuGet包地址:https://www.nuget.org/packages/Dispose.Scope

使用方式

Dispose.Scope使用非常简单,只需要几步就能完成上文中提到的功能。首先安装Nuget包:
NuGet​​​​​​​

Install-Package Dispose.Scopedotnet add package Dispose.Scopepaket add Dispose.Scope

你可以直接使用Dispose.Scope的API,本文的所有样例你都可以在samples文件夹中找到,比如我们有一个类叫NeedDispose代码如下:​​​​​​​

public class NeedDispose : IDisposable{    public NeedDispose(string name)    {        Name = name;    }    public string Name { get; set; }        public void Dispose()    {        Console.WriteLine("Dispose");    }}

然后我们就可以像下面这样使用DisposeScope

 
  • Dispose.Scope;using (var scope = DisposeScope.BeginScope()){ var needDispose = new NeedDisposeClass("A1"); // register to current scope needDispose.RegisterDisposeScope();}//
output: A1 Is Dispose

同样,在异步上下文中也可以使用DisposeScope:​​​​​​​

using (var scope = DisposeScope.BeginScope()){    await Task.Run(() =>    {        var needDispose = new NeedDispose("A2");        // register to current scope        needDispose.RegisterDisposeScope();    });}// 
output: A2 Is Dispose

当然我们可以在一个DisposeScope的作用域当中,嵌套多个DisposeScope,如果上下文中存在DisposeScope那么他们会直接使用上下文中的,如果没有那么他们会创建一个新的。​​​​​​​

using (_ = DisposeScope.BeginScope()){    var d0 = new NeedDispose("D0").RegisterDisposeScope();        using (_ = DisposeScope.BeginScope())    {        var d1 = new NeedDispose("D1").RegisterDisposeScope();    }    using (_ = DisposeScope.BeginScope())    {        var d2 = new NeedDispose("D2").RegisterDisposeScope();    }}// output:// D0 is Dispose// D1 is Dispose// D2 is Dispose

如果你想让嵌套的作用域优先释放,那么作用域调用BeginScope方法时需要指定DisposeScopeOption.RequiresNew(关于DisposeScopeOption选项可以查看下面的的内容),它不管上下文中有没有作用域,都会创建一个新的作用域:

using (_ = DisposeScope.BeginScope()){    var d0 = new NeedDispose("D0").RegisterDisposeScope();       using (_ = DisposeScope.BeginScope(DisposeScopeOption.RequiresNew))    {        var d1 = new NeedDispose("D1").RegisterDisposeScope();    }    using (_ = DisposeScope.BeginScope(DisposeScopeOption.RequiresNew))    {        var d2 = new NeedDispose("D2").RegisterDisposeScope();    }}// output:// D1 Is Dispose// D2 Is Dispose// D0 Is Dispose

如果你不想在嵌套作用域中使用DisposeScope,那么可以指定DisposeScopeOption.Suppress,它会忽略上下文的DisposeScope,但是如果你在没有DisposeScope上下文中使用RegisterDisposeScope,默认会抛出异常。​​​​​​​

using (_ = DisposeScope.BeginScope()){    var d0 = new NeedDispose("D0").RegisterDisposeScope();        using (_ = DisposeScope.BeginScope(DisposeScopeOption.RequiresNew))    {        var d1 = new NeedDispose("D1").RegisterDisposeScope();    }    using (_ = DisposeScope.BeginScope(DisposeScopeOption.Suppress))    {        // was throw exception, because this context is not DisposeScope        var d2 = new NeedDispose("D2").RegisterDisposeScope();    }}// output:// System.InvalidOperationException: Can not use Register on not DisposeScope context//    at Dispose.Scope.DisposeScope.Register(IDisposable disposable) in E:\MyCode\PooledScope\src\Dispose.Scope\DisposeScope.cs:line 100//    at Program.<<Main>$>g__Method3|0_4() in E:\MyCode\PooledScope\Samples\Sample\Program.cs:line 87//    at Program.<Main>$(String[] args) in E:\MyCode\PooledScope\Samples\Sample\Program.cs:line 9

如果不想让它抛出异常,那么只需要在开始全局设置DisposeScope.ThrowExceptionWhenNotHaveDisposeScope = false,在没有DisposeScope的上下文中,也不会抛出异常.

// set false, no exceptions will be thrownDisposeScope.ThrowExceptionWhenNotHaveDisposeScope = false;using (_ = DisposeScope.BeginScope()){    var d0 = new NeedDispose("D0").RegisterDisposeScope();        using (_ = DisposeScope.BeginScope(DisposeScopeOption.RequiresNew))    {        var d1 = new NeedDispose("D1").RegisterDisposeScope();    }    using (_ = DisposeScope.BeginScope(DisposeScopeOption.Suppress))    {        // no exceptions will be thrown        var d2 = new NeedDispose("D2").RegisterDisposeScope();    }}// output:// D1 Is Dispose// D0 Is Dispose

DisposeScopeOption

枚举描述
DisposeScopeOption.Required作用域内需要 DisposeScope。如果已经存在,它使用环境 DisposeScope。否则,它会在进入作用域之前创建一个新的 DisposeScope。这是默认值。
DisposeScopeOption.RequiresNew无论环境中是否有 DisposeScope,始终创建一个新的 DisposeScope
DisposeScopeOption.Suppress创建作用域时会抑制环境 DisposeScope 上下文。作用域内的所有操作都是在没有环境 DisposeScope 上下文的情况下完成的。

Collections.Pooled扩展

本项目一开始的初衷就是为了更方面的使用Collections.Pooled,它基于官方的System.Collections.Generic,实现了基于System.Buffers.ArrayPool的集合对象分配。
基于池的集合对象生成有着非常好的性能和非常低的内存占用。但是您在使用中需要手动为它进行Dispose,这在单一的方法中还好,有时您会跨多个方法,写起来会比较麻烦,而且有时会忘记去释放它,失去了使用Pool的意义,如下所示:

 

​​​​​​​

using Collections.Pooled;
Console.WriteLine(GetTotalAmount());decimal GetTotalAmount(){    // forget to dispose `MethodB` result    var result = GetRecordList().Sum(x => x.Amount);    return result;}PooledList<Record> GetRecordList(){    // register to dispose scope    var list = DbContext.Get().ToPooledList();    return list;}

现在您可以添加Dispose.Scope的类库,这样可以在外围设置一个Scope,当方法结束时,作用域内注册的对象都会Dispose

 
using Dispose.Scope;using Collections.Pooled;// dispose the scope all registered objectsusing(_ = DisposeScope.BeginScope){    Console.WriteLine(GetTotalAmount());}decimal GetTotalAmount(){    // forget to dispose `MethodB` result, but don't worries, it will be disposed automatically    var result = GetRecordList().Sum(x => x.Amount);    return result;}PooledList<Record> GetRecordList(){    // register to dispose scope, it will be disposed automatically    var list = DbContext.Get().ToPooledList().RegisterDisposeScope();    // or    var list = DbContext.Get().ToPooledListScope();    return list;}

性能

 
BenchmarkDotNet=v0.13.1, OS=Windows 10.0.22000Intel Core i7-8750H CPU 2.20GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores.NET SDK=6.0.203  [Host]     : .NET 6.0.5 (6.0.522.21309), X64 RyuJIT  DefaultJob : .NET 6.0.5 (6.0.522.21309), X64 RyuJIT
MethodMeanErrorStdDevRatioRatioSDGen 0Gen 1Gen 2Allocated
GetSomeClassUsePooledUsing169.4 ms1.60 ms1.50 ms0.700.0153333.333324333.3333-305 MB
GetSomeClassUsePooledScope169.6 ms1.47 ms1.30 ms0.700.0153000.000024333.3333-306 MB
GetSomeClass240.9 ms1.92 ms1.60 ms1.000.00112333.333358000.000041333.3333632 MB
GetSomeClassUsePooled402.2 ms7.78 ms8.96 ms1.680.0383000.000083000.000083000.0000556 MB

表格中GetSomeClassUsePooledScope就是使用Dispose.Scope的性能,可以看到它基本和手动using一样,稍微有一点额外的开销就是需要创建DisposeScope对象。

Asp.Net Core扩展

安装Nuget包Dispose.Scope.AspNetCore.
NuGet

 
Install-Package Dispose.Scope.AspNetCoredotnet add package Dispose.Scope.AspNetCorepaket add Dispose.Scope.AspNetCore

在Asp.Net Core中,返回给Client端是需要Json序列化的集合类型,这种场景下不太好使用Collections.Pooled,因为你需要在请求处理结束时释放它,但是你不能方便的修改框架中的代码,如下所示:

 
using Collections.Pooled;
[ApiController][Route("api/[controller]")]public class RecordController : Controller{    // you can't dispose PooledList<Record>    PooledList<Record> GetRecordList(string id)    {        return RecordDal.Get(id);    }}......public class RecordDal{    public PooledList<Record> Get(string id)    {        var result = DbContext().Get(r => r.id == id).ToPooledList();        return result;    }}

现在你可以引用Dispose.Scope.AspNetCore包,然后将它注册为第一个中间件(其实只要在你使用Pooled类型之前即可),然后使用ToPooledListScope或者RegisterDisposeScope方法;这样在框架的求处理结束时,它会自动释放所有注册的对象。

 
using Dispose.Scope.AspNetCore;var builder = WebApplication.CreateBuilder(args);builder.Services.AddControllers();var app = builder.Build();// register UsePooledScopeMiddleware// it will be create a scope when http request begin, and dispose it when http request endapp.UsePooledScope();app.MapGet("/", () => "Hello World!");app.MapControllers();app.Run();
......
[ApiController][Route("api/[controller]")]public class RecordController : Controller{    PooledList<Record> GetRecordList(string id)    {        return RecordDal.Get(id);    }}
......public class RecordDal{    public PooledList<Record> Get(string id)    {        // use `ToPooledListScope` to register to dispose scope        // will be dispose automatically when the scope is disposed        var result = DbContext().Get(r => r.id == id).ToPooledListScope();        return result;    }}

性能

在ASP.NET Core使用了DisposeScopePooledList,也使用普通的List作为对照组。使用https://github.com/InCerryGit/Dispose.Scope/tree/master/benchmarks代码进行压测,结果如下:

机器配置
Server:1 Core
Client:5 Core
由于是使用CPU亲和性进行绑核,存在Client抢占Server的Cpu资源的情况,结论仅供参考。

项目总耗时最小耗时平均耗时最大耗时QPSP95延时P99延时内存占用率
DisposeScope+PooledList199719.4805007193159MB
List201919.57749001931110MB

通过几次平均取值,使用Dispose.Scope结合PooledList的场景,内存占用率要低53%,QPS高了2%左右,其它指标基本没有任何性的退步。

注意

在使用Dispose.Scope需要注意一个场景,那就是在作用域内有跨线程操作时,比如下面的例子:

 
using Dispose.Scope;using(var scope = DisposeScope.BeginScope()){    // do something    _ = Task.Run(() =>    {        // do something        var list = new PooledList<Record>().RegisterDisposeScope();    });}

上面的代码存在严重的问题,当外层的作用域结束时,可能内部其它线程的任务还未结束,就会导致对象错误的被释放。如果您遇到这样的场景,您应该抑制上下文中的DisposeScope,然后在其它线程中重新创建作用域。

 
using Dispose.Scope;using(var scope = DisposeScope.BeginScope()){    // suppress context scope    using(var scope2 = DisposeScope.BeginScope(DisposeScopeOption.Suppress))    {        _ = Task.Run(() =>        {            // on other thread create new scope            using(var scope = DisposeScope.BeginScope())            {                // do something                var list = new PooledList<Record>().RegisterDisposeScope();            }        });    }
}

总结

本文对上一篇文章中大家问的比较多的几个问题统一回答了一下,另外就是介绍了一种使用作用域管理Dispose对象的一个类库。不过也要告诫大家,在采用新的框架和技术之前一定要充分评估,到底应不应该使用这个技术,会带来哪些风险,然后进行详细的测试。

import matplotlib.pyplot as plt import numpy as np # 强制中文字体 plt.rcParams['font.sans-serif'] = ['SimHei'] plt.rcParams['axes.unicode_minus'] = False # 数据定义 # 链路1:线索-电话转化 lead_labels = ['全部线索', '接通电话', '未接通'] lead_counts = [71, 59, 12] lead_ratios = ['100%', '83%', '17%'] lead_colors = ['#a6cee3', '#1f78b4', '#cccccc'] # 链路2:接通-商机转化 biz_labels = ['有效商机', '无效商机'] biz_counts = [41, 30] biz_ratios = ['57.7%', '42.3%'] biz_colors = ['#33a02c', '#ff7f00'] # 链路3:有效商机-订单拆分 order_labels = ['A单', 'B单', 'C单', 'S单'] order_counts = [20, 17, 3, 1] order_ratios = ['48.8%', '41.5%', '7.3%', '2.4%'] order_colors = ['#1f78b4', '#e31a1c', '#ff7f00', '#666666'] # 核心组合 combo_labels = ['A+B+S组合', 'C单'] combo_counts = [38, 3] combo_ratios = ['92.7%', '7.3%'] combo_colors = ['#33a02c', '#ff7f00'] # 创建2x2布局 fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(12, 10)) # 链路1:线索→电话转化(横向柱状图) ax1.barh(lead_labels, lead_counts, color=lead_colors, edgecolor='white', linewidth=1) ax1.set_title('链路1:线索→电话转化', fontsize=12, fontweight='bold') ax1.set_xlabel('数量') for i, (cnt, ratio) in enumerate(zip(lead_counts, lead_ratios)): ax1.text(cnt + 0.5, i, f'{cnt} ({ratio})', ha='left', va='center', fontsize=10) ax1.spines['top'].set_visible(False) ax1.spines['right'].set_visible(False) ax1.grid(axis='x', linestyle='--', alpha=0.3) # 链路2:接通→商机转化(饼图) ax2.pie(biz_counts, labels=biz_labels, colors=biz_colors, autopct='%1.1f%%', startangle=90, textprops={'fontsize': 10, 'fontweight': 'bold'}) ax2.set_title('链路2:接通→商机转化', fontsize=12, fontweight='bold') for text in ax2.texts: if text.get_text() not in biz_labels: text.set_color('white') # 链路3:有效商机→订单拆分(纵向柱状图) ax3.bar(order_labels, order_counts, color=order_colors, edgecolor='white', linewidth=1) ax3.set_title('链路3:有效商机→订单拆分', fontsize=12, fontweight='bold') ax3.set_ylabel('数量') for i, (cnt, ratio) in enumerate(zip(order_counts, order_ratios)): ax3.text(i, cnt + 0.2, f'{cnt} ({ratio})', ha='center', va='bottom', fontsize=10) ax3.spines['top'].set_visible(False) ax3.spines['right'].set_visible(False) ax3.grid(axis='y', linestyle='--', alpha=0.3) # 核心组合占比(纵向柱状图) ax4.bar(combo_labels, combo_counts, color=combo_colors, edgecolor='white', linewidth=1) ax4.set_title('核心组合:A+B+S占有效商机92.7%', fontsize=12, fontweight='bold') ax4.set_ylabel('数量') for i, (cnt, ratio) in enumerate(zip(combo_counts, combo_ratios)): ax4.text(i, cnt + 0.5, f'{cnt} ({ratio})', ha='center', va='bottom', fontsize=10) ax4.spines['top'].set_visible(False) ax4.spines['right'].set_visible(False) ax4.grid(axis='y', linestyle='--', alpha=0.3) # 整体标题 fig.suptitle('商机数据全链路看板', fontsize=16, fontweight='bold', y=0.98) # 调整布局 plt.tight_layout() plt.subplots_adjust(top=0.9) plt.savefig('商机数据全链路看板_最终版.png', dpi=300, bbox_inches='tight') plt.close() print("✅ 商机数据全链路看板已成功生成!")
最新发布
10-20
评论 1
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值