简介
在上一篇.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,但是接口契约是IList IList<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框架还需要帮我们使用比如json
、xml
等格式序列化,我们不好去修改框架的行为,给它人为加一个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.Scope dotnet add package Dispose.Scope paket 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
:
using 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:MyCodePooledScopesrcDispose.ScopeDisposeScope.cs:line 100 // at Program.<<Main>$>g__Method3|0_4() in E:MyCodePooledScopeSamplesSampleProgram.cs:line 87 // at Program.<Main>$(String[] args) in E:MyCodePooledScopeSamplesSampleProgram.cs:line 9
如果不想让它抛出异常,那么只需要在开始全局设置DisposeScope.ThrowExceptionWhenNotHaveDisposeScope = false
,在没有DisposeScope
的上下文中,也不会抛出异常.
// set false, no exceptions will be thrown DisposeScope.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 objects using(_ = 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.22000 Intel 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
Method | Mean | Error | StdDev | Ratio | RatioSD | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|---|---|---|
GetSomeClassUsePooledUsing | 169.4 ms | 1.60 ms | 1.50 ms | 0.70 | 0.01 | 53333.3333 | 24333.3333 | - | 305 MB |
GetSomeClassUsePooledScope | 169.6 ms | 1.47 ms | 1.30 ms | 0.70 | 0.01 | 53000.0000 | 24333.3333 | - | 306 MB |
GetSomeClass | 240.9 ms | 1.92 ms | 1.60 ms | 1.00 | 0.00 | 112333.3333 | 58000.0000 | 41333.3333 | 632 MB |
GetSomeClassUsePooled | 402.2 ms | 7.78 ms | 8.96 ms | 1.68 | 0.03 | 83000.0000 | 83000.0000 | 83000.0000 | 556 MB |
表格中GetSomeClassUsePooledScope
就是使用Dispose.Scope
的性能,可以看到它基本和手动using
一样,稍微有一点额外的开销就是需要创建DisposeScope
对象。
Asp.Net Core扩展
安装Nuget包Dispose.Scope.AspNetCore
.
NuGet
Install-Package Dispose.Scope.AspNetCore dotnet add package Dispose.Scope.AspNetCore paket 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 end app.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使用了DisposeScope
和PooledList
,也使用普通的List
作为对照组。使用https://github.com/InCerryGit/Dispose.Scope/tree/master/benchmarks
代码进行压测,结果如下:
机器配置
Server:1 Core
Client:5 Core
由于是使用CPU亲和性进行绑核,存在Client抢占Server的Cpu资源的情况,结论仅供参考。
项目 | 总耗时 | 最小耗时 | 平均耗时 | 最大耗时 | QPS | P95延时 | P99延时 | 内存占用率 |
---|---|---|---|---|---|---|---|---|
DisposeScope+PooledList | 1997 | 1 | 9.4 | 80 | 5007 | 19 | 31 | 59MB |
List | 2019 | 1 | 9.5 | 77 | 4900 | 19 | 31 | 110MB |
通过几次平均取值,使用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
对象的一个类库。不过也要告诫大家,在采用新的框架和技术之前一定要充分评估,到底应不应该使用这个技术,会带来哪些风险,然后进行详细的测试。