.NET性能优化-使用RecyclableMemoryStream替代MemoryStream


提到MemoryStream大家可能都不陌生,在编写代码中或多或少有使用过;比如Json序列化反序列化、导出PDF/Excel/Word、进行图片或者文字处理等场景。但是如果使用它高频、大数据量处理这些数据,就存在一些性能陷阱。

今天给大家带来的这个优化技巧其实就是池化MemoryStream的版本RecyclableMemoryStream,它通过池化MemoryStream底层buffer来降低内存占用率、GC暂停时间和GC次数达到提升性能目的。

它的开源库地址如下链接:

https://github.com/microsoft/Microsoft.IO.RecyclableMemoryStream

使用它也非常简单,直接安装对应的Nuget包即可,目前最新版本是2.2.1版本。

// 命令行安装
dotnet add package Microsoft.IO.RecyclableMemoryStream --version 2.2.1
// csproj 安装

然后创建一个RecyclableMemoryStreamManager对象,即可使用它的GetStream方法来获取一个池化的流,当然使用完这个流以后需要调用Dispose方法将其归还到池中,也可以使用using模式来释放。

class Program
{
    private static readonly RecyclableMemoryStreamManager manager = new RecyclableMemoryStreamManager();

    static void Main(string[] args)
    {
        var sourceBuffer = new byte[] { 0, 1, 2, 3, 4, 5, 6, 7 };
        
        using (var stream = manager.GetStream())
        {
            stream.Write(sourceBuffer, 0, sourceBuffer.Length);
        }
    }
}

在创建RecyclableMemoryStreamManagerGetStream时有很多选项,可以设置底层buffer的大小、为流进行命名隔离等精细化的选项,这些大家可以看官方文档了解,本文不再赘述。

性能比较

为了直观的比较性能,我构建了一个Benchmark,这个基准测试分别使用MemoryStreamRecyclableMemoryStream实现数据缓冲的功能,下面是测试代码:

public class BenchmarkRecyclableMemoryStream
{
    // 生成随机数
    private static readonly Random Random = new(1024);

    // 填充的数据
    private static readonly byte[] Data = Enumerable.Range(0, 81920).Select(d => (byte) d).ToArray();
    // 每次随机填充
    private static readonly int[] DataLength = Enumerable.Range(0, 1000).Select(d => Random.Next(10240, 81920)).ToArray();

    // RecyclableManager
    private static readonly RecyclableMemoryStreamManager Manager = new();
    
    [Benchmark(Baseline = true)]
    public long UseMemoryStream()
    {
        var sum = 0L; 
        for (int i = 0; i < DataLength.Length; i++)
        {
            using var stream = new MemoryStream();
            stream.Write(Data, 0, DataLength[i]);
            sum += stream.Length;
        }

        return sum;
    }

    [Benchmark]
    public long UseRecyclableMemoryStream()
    {
        var sum = 0L; 
        for (int i = 0; i < DataLength.Length; i++)
        {
            using var stream = Manager.GetStream();
            stream.Write(Data, 0, DataLength[i]);
            sum += stream.Length;
        }

        return sum;
    }
}

下方是测试的结果,可以看到使用RecyclableMemoryStream比直接使用MemoryStream在内存和速度上有很大的优势。

  • 执行效率快51%
  • 内存分配要低99.4%

工作原理

RecyclableMemoryStream提升GC性能的方式是通过将缓冲区分配和保持在第二代堆,这能减少FullGC的频率,另外如果您设置的缓冲区大小超过85,000字节,那么缓冲区将分配在LOH上,GC不会经常扫描这些对象堆。

RecyclableMemoryStreamManager类维护了两个独立的对象池:

  • 小型池:保存小型缓冲区(可配置大小),默认情况下用于所有正常的读、写操作,多个小的缓冲区能链接在一起,形成单独的Stream
  • 大型池:保存大型缓冲区,只有在必须需要单个且连续缓冲区才使用,比如调用GetBuffer方法,它可以创建比单个缓冲区大的多的Stream,最大不超过.NET对数组类型的限制。

RecyclableMemoryStream首先会使用一个小的缓冲区,随着写入数据的增多,会将其它缓冲区链接起来组合使用。如果您调用了GetBuffer方法,并且已有的数据大于单个小缓冲区的容量,那么就会被转换为大缓冲区。

另外您还可以为Stream设置初始容量,如果容量大于单个缓冲区大小,会在一开始就链接好多个块,当然也可以直接分配大型缓冲区,只需将asContiguousBuffer设置为true。

大型池有两个版本:

  • 线性(默认):指定一个倍数和最大的大小,然后创建一个缓冲区数组,从(1x倍数)、(2x倍数)一直到最大值。
  • 指数:缓冲区不是线性增长而是指数增长,每个槽大小将增加一倍。

如下图所示:

那么您应该用哪一个?这取决于您的业务场景。如果您的缓冲区大小不可预测,那么线性缓冲区可能更合适。如果您知道不可能分配较长的流长度,但是可能有很多较小尺寸的流,那么选择指数版本可能会导致较少的总体内存使用。

缓冲区是在第一次被请求时按需创建的。使用完Stream后,这些缓冲区将通过RecyclableMemoryStreamDispose方法返回到池中。当这种返回发生时,RecyclableMemoryStreamManager将使用属性MaximumFreeSmallPoolBytesMaximumFreeLargePoolBytes来决定是否将这些缓冲区放回池中,或者让它们离开(从而被垃圾收集)。正是通过这些属性,你决定了你的池子可以增长到多大。如果你把这些属性设置为0,你就会有无限制的池增长,这与内存泄漏基本上没有区别。对于每一个应用程序,你必须通过分析和实验来确定内存池大小和垃圾收集之间的适当平衡。

如果忘记调用流的 Dispose 方法,可能会导致内存泄漏。为了帮助您避免这种情况,每个流都有一个终结器,一旦没有更多对流的引用,CLR 将调用该终结器。此终结器将引发有关泄漏流的事件或记录有关泄漏流的消息。

请注意,由于性能原因,缓冲区从来没有预先初始化或归零。您有责任确保它们的内容是有效和安全的,可以使用缓冲区回收。

使用指南

虽然这个库力求非常通用化,并且不会对如何使用它施加太多限制,但是它的目的是减少由于频繁的大量分配而产生的垃圾收集的成本。因此,以下是一些对你有用的通用使用指南:

  1. blockSizelargeBufferMultiplemaxBufferSizeMaximumFreeLargePoolBytes MaximumFreeSmallPoolBytes属性设置为符合你的应用和资源要求的合理值。如果你不设置MaximumFreeLargePoolBytesMaximumFreeSmallPoolBytes,就有可能出现无限制的内存增长!
  2. 每个流总是被精确地Dispose一次。
  3. 大多数应用程序不应该调用ToArray,如果可能,应该避免调用GetBuffer。相反,使用GetReadOnlySequence来读取,使用IBufferWriter方法GetSpanGetMemoryAdvance来写入。还有一些杂七杂八的CopyToWriteTo方法,可能很方便。重点是要尽可能避免产生不必要的GC压力。
  4. 通过实验找到适合你情况的设置。

在你尝试用这个库来优化你的方案之前,对垃圾收集器有一定的了解是一个非常好的主意。像垃圾收集这样的文章,或者像《编写高性能的.NET代码》这样的书,将帮助你理解这个库的设计原则。

在配置选项时,要考虑这样的问题。

  • 我期望的流的长度分布是怎样的?
  • 有多少个流会在同一时间被使用?
  • GetBuffer是否经常被调用?我需要多大程度的使用大型池缓冲区?
  • 我需要对活动高峰有多大的弹性? 即我应该保留多少空闲字节以备不时之需?
  • 我在要使用的机器上有哪些物理内存限制?

总结

本文中介绍了一个通用的MemoryStream池化库,使用它能显著的提升你系统的性能,你几乎可以在任何场景使用RecyclableMemoryStream替代MemoryStream。要知道在我们性能评测中,RecyclableMemoryStreamMemoryStream快51%,而且它能节省99.4%的内存分配。

.NET性能优化交流群

相信大家在开发中经常会遇到一些性能问题,苦于没有有效的工具去发现性能瓶颈,或者是发现瓶颈以后不知道该如何优化。之前一直有读者朋友询问有没有技术交流群,但是由于各种原因一直都没创建,现在很高兴的在这里宣布,我创建了一个专门交流.NET性能优化经验的群组,主题包括但不限于:

  • 如何找到.NET性能瓶颈,如使用APM、dotnet tools等工具
  • .NET框架底层原理的实现,如垃圾回收器、JIT等等
  • 如何编写高性能的.NET代码,哪些地方存在性能陷阱

希望能有更多志同道合朋友加入,分享一些工作中遇到的.NET性能问题和宝贵的性能分析优化经验。由于已经达到200人,可以加我微信,我拉你进群: ls1075