.NET高性能类及其优化技巧

基础知识

一般的,结构体的实例存储在栈中,引用类型存储在托管堆中

  • :空间比较,但是读取速度
  • :空间比较,但是读取速度

引用结构体ref struct的数据保存在中,因此它的读写速度非常快,另一方面,栈中的数据销毁很快,而不是像托管堆一样,交给GC去回收

在大多数情况下,连续的内存操作比非连续性的内存操作要快特别是读操作, 多线程下连续内存的写由于CPU缓存伪共享问题性能反而可能下降

高性能操作

Span/ReadOnlySpan 和 Memory/ReadOnlyMemory:快速访问内存的方法,无需进行不必要的复制操作。

Memory是什么?
它是一种可变大小、可读写的内存块,可以安全地暴露给用户代码进行操作。

  • Memory是可变的,所以我们可以直接在内存中操作数据,而不需要进行额外的拷贝操作。例如,当你需要从一个字节数组中获取一个子数组时,传统做法可能需要先分配一个新的数组,并将原数组中的数据复制到新数组中,而使用Memory则可以直接创建一个指向原数组的Memory对象,并通过切片等操作来获取子数组,避免了不必要的内存分配和拷贝。
  • 使用Memory还可以减少垃圾回收的压力,因为我们不需要创建新的对象来存储数据。
  • Memory还可以与Span和ReadOnlySpan类型一起使用,这些类型可以方便地对数据进行访问和操作。
Memory<byte> emptyMemory = Memory<byte>.Empty; // 创建一个空内存块
byte[] byteArray = new byte[10]; 
Memory<byte> memory = byteArray; // 数组=>内存块
byte[] array = memory.Span.ToArray(); // 内存块=>数组
Memory<byte> slice = memory.Slice(2, 2); // 内存切片

注意:当你将byte[]数组传递给Memory的构造函数或者使用AsMemory()方法时,Memory会引用同一块内存,而不会进行内存复制。这意味着对Memory的修改会直接反映在原始的byte[]数组上,反之亦然。

byte[] byteArray = new byte[] { 0x12, 0x34, 0x56, 0x78, 0x9A };
Memory<byte> byteMemory = byteArray.AsMemory();
// 修改Memory<byte>
byteMemory.Span[0] = 0xAB;
Console.WriteLine(byteArray[0]);  // 输出:0xAB

注意:Span<T> 就是利用 ref struct 的产物

Span<T>中并不直接存储数据,它主要包含两个主要的信息:一个是指向数据的指针(或引用),另一个是数据的长度。这使得Span<T>能够表示一个连续的内存块,但并不实际拥有或复制这些数据。因此,当你创建一个Span<T>时,你实际上只是创建了一个轻量级的结构来引用现有的内存区域,而没有涉及任何数据的移动或复制。

System.IO.Pipelines:用于高性能 I/O。

System.IO.Pipelines 是一个用于读写数据流的高性能 API。它主要由三个部分组成:PipePipelineReaderPipelineWriter

Pipe 是一个异步线程安全缓冲区,它让数据在生产者和消费者之间流动。PipelineReaderPipelineWriter 则是 Pipe 的读取和写入端点。

有什么优点?

  • 高性能:能够处理大量数据,而且不需要额外的内存分配,这意味着你可以减少内存使用量。
  • 低延迟:它能够在不阻塞线程池中的线程的情况下处理数据,这意味着你的应用程序能够更快地响应请求。
  • 异步读写:支持异步读写,这意味着你的应用程序能够同时处理多个请求,而不会阻塞线程池中的线程。
  • 可扩展性:可以很容易地扩展到多个处理器,从而实现高并发处理。

有哪些应用场景?

  • 网络编程: 它能够帮你高效地处理大量的网络数据流。你可以使用 PipelineWriter 将数据写入缓冲区,在另一个线程中使用 PipelineReader 读取缓冲区中的数据,并进行处理。
  • 文件处理: 你可以将文件分块读取到缓冲区中,然后使用 PipelineReader 读取缓冲区中的数据,并进行处理。这样可以大大减少内存分配和文件 I/O 的开销,从而提高文件处理的效率。

怎么使用?

  • 创建Pipe:创建一个缓冲区,用于读取和写入数据。
  • 写入数据:使用 PipelineWriter 将数据写入缓冲区。
  • 读取数据:使用 PipelineReader 读取缓冲区中的数据,并进行处理。

使用 ValueTask 代替 Task

ValueTask:轻量级任务类型,表示可能异步完成的操作。

实现异步迭代器时,C#编译器会利用此优势,以使异步迭代器尽可能免于额外内存分配。

System.Buffers:该库提供了一组用于创建和管理缓冲区的类。

这里面有些重点类, 只列出来, 后面单独讲解:

  • System.Buffers.ArrayPool<T> 数组池, 返回的是byte[]
  • System.Buffers.MemoryPool<T> 内存池, 返回的是IMemoryOwner
  • System.Buffers.IMemoryOwner<T> 内存池的返回类型, 其内部实现Memory
  • System.Buffers.IBufferWriter<T> 与其实现 ArrayBufferWriter<T> 这个不常用, 但是在一些高级类中会遇到, 通常用在复杂的业务场景中, 简单的业务场景直接使用Span或Memory即可
  • System.Buffers.ReadOnlySequence<T> 只读序列, 用于表示非连续内存序列的结构。
  • System.Buffers.ReadOnlySequenceSegment<T> 只读序列分段, 一个或多个分段可以组成一个ReadOnlySequence
  • System.Buffers.SequenceReader<T> 用于高效读取序列数据的工具, 它特别适用于处理 ReadOnlySequence 类型的数据
  • System.Buffers.SequenceReaderExtensions 是SequenceReader类的扩展类, 提供了从二进制数据读取字节序特定数值的功能
  • System.Buffers.SearchValues<T> 对ReadOnlySpan进行高效搜索,其内只有一个Contains(T)方法

注意: ReadOnlySequence相关类使用比较复杂, 用好了可以极大提高性能降低内存, 用不好就会适得其反.

System.Buffers.ReadOnlySequence 只读序列,不连续的Memory

内存片段 ReadOnlySequenceSegment 是 ReadOnlySequence 的基础。

在我们读取数据的过程,很多时候会出现如下场景:不知道数据实际大小, 一次性申请大量内存开销太大, 此时我们往往会使用动态内存的方案,通过链表的方式串联起来,从而形成逻辑意义上的数据流。可以理解成非连续内存的ReadOnlyMemory<T>组成的链表, 我们在使用它时可以看成一个虚拟的ReadOnlyMemory

其中System.IO.Pipelines它扮演了重要的角色.

扩展阅读: https://www.cnblogs.com/jionsoft/p/13676277.html

ArraySegment数组段(分隔一维数组的一部分)

它表示数组的一段连续区域或片段。当你需要处理数组的不同部分而不复制整个数组时,ArraySegment 非常有用。原始数组必须是一维数组,并且必须具有从零开始的索引。

总的来说,ArraySegment 更适合用于传统数组操作中的片段引用和修改,而 Span 则更适合用于高性能和内存安全的内存操作。在现代 C# 中,通常推荐使用Span来代替ArraySegment,以获得更好的性能和灵活性。但在一些高性能库中还是经常会看到ArraySegment

ArraySegment<T>在做数组切片时不会发生内存复制,且其可以很方便的转化成ReadOnlySpan<T>, 但是其不能直接转化为Array如果要转化会发生内存复制,因为Array内存结构中有一个数组长度,如果要不发生内存复制的情况下转化那么必须要破坏源数组结构

System.Numerics大型整数的类

该库提供了一组用于处理大型整数的类。

System.Collections.Frozen

不可变只读集合,它的创建成本相对较高,但提供出色的查找性能, 非常适用于配置类。其不同于readonly描述符, readonly创建后可以更改key/value,只是不能重新赋值(=), 但是frozen创建后key/value也不能修改.

Pool池类

System.Buffers.ArrayPool

  • 需要频繁分配和释放小型内存块的情况,例如在高性能计算、并行处理或缓存中使用。
  • 对内存使用量敏感的情况,例如在资源受限的环境中使用。

System.Buffers.MemoryPool

(注意:内存池中无Return方法,GC会高效回收, 池子本质申请的是Memory
需使用using()自动释放,或者Dispose()手动释放

  • 需要动态分配较大内存块的情况,例如处理大型文件、网络数据流等。
  • 长时间持有内存块的情况,例如在长时间运行的服务中使用。
  • 需要更高级别的内存管理控制也就是Memory<T>,例如自定义的内存管理策略。

Microsoft.Extensions.ObjectPool.ObjectPool

对象池, 使用对象池在返回池时需要进行初始化

System.Threading.ThreadPool / Task

线程池

使用 ref struct 做到 0 GC

C# 7 开始引入了一种叫做 ref struct 的结构,这种结构本质是 struct ,结构存储在栈内存。但是与 struct 不同的是,该结构不允许实现任何接口,并由编译器保证该结构永远不会被装箱,因此不会给 GC 带来任何的压力。其中 Span<T> 就是利用 ref struct 的产物

使用 unsafe

代码块使用 unsafe 修饰符标记时,C# 允许在函数中使用指针变量。不安全代码或非托管代码是指使用了指针变量的代码块。

使用 stackalloc 在上分配连续内存

对于部分性能敏感却需要使用少量的连续内存的情况,不必使用数组,而可以通过 stackalloc 直接在上分配内存,并使用 Span<T> 来安全的访问,同样的,这么做可以做到 0 GC 压力。

连续内存的数据结构有如下: 数组/字符串/结构体/枚举

使用不可变类型

不可变类型是一类特殊的类型,它们被设计为创建后就不能被修改。优点:线程安全/缓存和复用/易于维护/函数式编程, 特别适用于多线程和分布式系统等需要高度并发和异步编程的场景。

有如下一些操作使用不可变数据

  • 使用 in关键字传递不可修改的引用给方法
  • 使用 record类型和不可变数据
  • 使用 readonly类型
  • 使用不可变类型, 例如:字符串/元组/值类型/只读属性/不可变集合类(ReadOnlyDictionary/FrozenDictionary等)

BinaryPrimitives 细粒度操作字节数组

System.Buffers.Binary.BinaryPrimitives命名空间 BinaryPrimitives 的实现原理是 BitConverter,BinaryPrimitives 对 BitConverter 做了一些封装。BinaryPrimitives 中有大端小端之分, 在向byte[]中写入和读取 基本类型 时经常用到此类

MemoryMarshal可以将一种结构转换为另一种结构

MemoryMarshal 提供与 Memory、ReadOnlyMemory、Span 和 ReadOnlySpan 进行交互操作的方法。最简单的说法是,MemoryMarshal 可以将一种结构转换为另一种结构。

var byteArray = new byte[] { 1, 0, 0, 0, 2, 0, 0, 0 };
Span<byte> byteSpan = byteArray.AsSpan();
Span<int> intSpan = MemoryMarshal.Cast<byte, int>(byteSpan); // int [] {1,2}

System.Runtime.Caching.MemoryCache

缓存类, 类似memcache/redis

RecyclableMemoryStream 代替 MemoryStream

注意: 在处理大的流时采用RecyclableMemoryStream, 在处理小的流时采用MemoryStream。因为测试发现用于处理小的流(百个字节以内)时RecyclableMemoryStream比MemoryStream慢几倍, 内存消耗也相差不大。 但是处理几万字节流时, 其内存消耗比MemoryStream少百倍, 其getSpan等的耗时比MemoryStream少几十倍。

RecyclableMemoryStream通过池化MemoryStream底层buffer来降低内存占用率、GC暂停时间和GC次数达到提升性能目的, 开始Stream会写入小型池,当小型池装不下时会复制到大型池。这个库相比其他Stream的使用方法有所不同, 主要表现如下:

  • RecyclableMemoryStreamManager用于管理RecyclableMemoryStream的池等
  • 最好不要调用toArray()会重新new byte[]分配内存
  • 最好少调用GetBuffer()TryGetBuffer() 因为它会将小型池内容复制到大型池,会发生一次内存复制
  • 最好少调用Write()因为大量调用会导致小型池大量的内存片段, 最好一个Stream只调用几个, 千万不要在循环中调用, 否则再调用GetBuffer就会将小型池的内存片段复制到大型池再返回, 会有大量性能损失
  • 写入时调用GetWritableBuffer(),GetSpan(),GetMemory()获取一个buffer的缓冲引用, 然后在内存引用中添加/修改, 之后再调用Advance()将缓冲区数据写入到小型池/大型池/缓冲区
  • 读取时调用GetReadOnlySequence()可以以内存片段形式返回, 不会发生任何内存复制
private static readonly RecyclableMemoryStreamManager manager = new RecyclableMemoryStreamManager();
using var memoryStream = manager.GetStream();

# 不推荐的写法
# 注意, 这样大量调用 Write(1) 会出现性能问题, 所以, 其最好不要和系统内置的 binaryReader 等库操作.
using BinaryReader binaryReader = new BinaryReader(memoryStream);
for (int i = 0; i < 1024; i++)
{
    binaryReader.Write(1);
}
memoryStream.toArray();

# 推荐写法
// 获取缓冲区(具体是获取小型池/大型池/还是缓冲区的块, 取决于数据量大小)
Span<byte> buffs = memoryStream.GetSpan(1024);
// 写入&修改缓冲区
for (int i = 0; i < 1024; i++)
{
    buffs[i] = 1;
}
// 缓冲区写入小型池(小数据)/大型池(启用大型池时)/缓冲区(大于定义的块时)
// 如果是 小型池/大型池 调用不需要复制内存因为Span就已经直接修改了, 如果是 缓冲区 有一次内存复制但不会产生gc
memoryStream.Advance(1024)

异步编程

异步编程可以让你的应用程序在等待某些操作(如I/O操作)完成时不会阻塞主线程,从而提高应用程序的性能和响应性。使用async和await关键字可以简化异步编程。

细节优化技巧

  • 使用简单的数据结构:尽量使用简单的数据结构,如数组而不是列表(List)、结构体而不是类等。
  • 避免装箱和拆箱:尽量避免值类型和引用类型之间的转换,因为这会引入额外的开销。
  • 使用不可变类型:不可变类型可以减少并发环境下的竞争和复杂性,提高性能。
  • 使用内存池:适当地重用对象,可以通过内存池来避免频繁地分配和回收内存。
  • 避免使用反射:反射操作会比直接调用方法或访问属性慢很多,尽量避免在性能敏感的代码路径中使用反射。
  • 使用并行编程:在需要处理大量数据或进行并行计算时,可以考虑使用并行编程库,如 Parallel 类或异步编程模式。
  • 优化关键路径:对于性能关键的代码路径,进行适当的优化,比如减少循环内部的计算、减少内存分配等。
  • 使用性能分析工具:利用性能分析工具(如 Profiler)来找出性能瓶颈,并有针对性地进行优化。
  • 使用StringBuilder而不是String:在C#中,字符串是不可变的。这意味着每次对字符串进行修改时,都会生成一个新的字符串对象。这可能会导致大量不必要的内存分配和垃圾回收。对于需要多次修改字符串的情况,最好使用StringBuilder类,这是一个可变对象,可以更有效地处理字符串。
  • 使用异步编程:异步编程可以让您在等待某些操作(如I/O操作)完成时,同时执行其他操作。在C#中,async和await关键字是处理异步操作的常用方式。
  • 合适的数据结构和算法:选择正确的数据结构和算法可以极大地提高代码的性能。例如,如果您需要频繁地在列表中查找元素,那么可能应该使用HashSet而不是List。

其他优化

  • .NET 5引入了AOT,.NET Native是一个AOT编译器,通过预先将.NET应用程序编译为本地机器代码,加快了应用程序的启动时间和执行效率。
  • PGO (Profile-Guided Optimization) 是使用配置文件引导的优化,是一种编译器优化技术,借助配置文件来引导编译,达到提高程序运行时性能的目的。PGO通过收集运行时信息来指导JIT如何优化代码,相比以前没有PGO时可以做更多以前难以完成的优化。这项优化是一个通用技术,不局限于某一门语言。

扩展阅读

【译】ASP.NET Core 6 中的性能改进
ASP.NET Core 7 中的性能改进
【译】.NET 7 中的性能改进
.NET高性能编程Span/Memory
在 C# 中使用 Span 和 Memory 编写高性能代码
System.IO.Pipelines: C#高性能IO
C# 使用Pipelines处理Socket数据包
github.com/kk-cpp/KeenNet
编写高效的代码,你应该了解Array、Memory、ReadOnlySequence

此处评论已关闭