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

虚幻大学 xuhss 147℃ 0评论

? 优质资源分享 ?

学习路线指引(点击解锁) 知识定位 人群定位
? Python实战微信订餐小程序 ? 进阶级 本课程是python flask+微信小程序的完美结合,从项目搭建到腾讯云部署上线,打造一个全栈订餐系统。
?Python量化交易实战? 入门级 手把手带你打造一个易扩展、更安全、效率更高的量化交易系统

简介

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

问题分析

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

ArrayPool会不会无限扩大?

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

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

// 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框架还需要帮我们使用比如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.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:\MyCode\PooledScope\src\Dispose.Scope\DisposeScope.cs:line 100
// at Program.<$>g\_\_Method3|0\_4() in E:\MyCode\PooledScope\Samples\Sample\Program.cs:line 87
// at Program.$(String[] args) in E:\MyCode\PooledScope\Samples\Sample\Program.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 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 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
    PooledList GetRecordList(string id)
    {
        return RecordDal.Get(id);
    }
}
......
public class RecordDal
{
    public PooledList 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 GetRecordList(string id)
    {
        return RecordDal.Get(id);
    }
}

......
public class RecordDal
{
    public PooledList 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资源的情况,结论仅供参考。

项目 总耗时 最小耗时 平均耗时 最大耗时 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().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().RegisterDisposeScope();
 }
 });
 }

}

总结

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

转载请注明:xuhss » .NET性能优化-推荐使用Collections.Pooled(补充)

喜欢 (0)

您必须 登录 才能发表评论!