.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同一块内存
,而不会进行内存复制。这意味着对Memory
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。它主要由三个部分组成:Pipe
、PipelineReader
和 PipelineWriter
。
Pipe
是一个异步
、线程安全
的缓冲区
,它让数据在生产者和消费者之间流动。PipelineReader
和 PipelineWriter
则是 Pipe
的读取和写入端点。
有什么优点?
高性能
:能够处理大量数据,而且不需要额外的内存分配,这意味着你可以减少内存使用量。低延迟
:它能够在不阻塞线程池中的线程的情况下处理数据,这意味着你的应用程序能够更快地响应请求。异步读写
:支持异步读写,这意味着你的应用程序能够同时处理多个请求,而不会阻塞线程池中的线程。可扩展性
:可以很容易地扩展到多个处理器,从而实现高并发处理。
有哪些应用场景?
网络编程:
它能够帮你高效地处理大量的网络数据流。你可以使用 PipelineWriter 将数据写入缓冲区,在另一个线程中使用 PipelineReader 读取缓冲区中的数据,并进行处理。文件处理:
你可以将文件分块读取到缓冲区中,然后使用 PipelineReader 读取缓冲区中的数据,并进行处理。这样可以大大减少内存分配和文件 I/O 的开销,从而提高文件处理的效率。
怎么使用?
创建Pipe
:创建一个缓冲区,用于读取和写入数据。写入数据
:使用PipelineWriter
将数据写入缓冲区。读取数据
:使用PipelineReader
读取缓冲区中的数据,并进行处理。
使用 ValueTask 代替 Task
ValueTask
实现异步迭代器时,C#编译器会利用此优势,以使异步迭代器尽可能免于额外内存分配。
System.Buffers:该库提供了一组用于创建和管理缓冲区的类。
这里面有些重点类, 只列出来, 后面单独讲解:
System.Buffers.ArrayPool<T>
数组池, 返回的是byte[]System.Buffers.MemoryPool<T>
内存池, 返回的是IMemoryOwnerSystem.Buffers.IMemoryOwner<T>
内存池的返回类型, 其内部实现MemorySystem.Buffers.IBufferWriter<T>
与其实现ArrayBufferWriter<T>
这个不常用, 但是在一些高级类中会遇到, 通常用在复杂的业务场景中, 简单的业务场景直接使用Span或Memory 即可 System.Buffers.ReadOnlySequence<T>
只读序列, 用于表示非连续内存序列的结构。System.Buffers.ReadOnlySequenceSegment<T>
只读序列分段, 一个或多个分段可以组成一个ReadOnlySequenceSystem.Buffers.SequenceReader<T>
用于高效读取序列数据的工具, 它特别适用于处理 ReadOnlySequence类型的数据 System.Buffers.SequenceReaderExtensions
是SequenceReader类的扩展类, 提供了从二进制数据读取字节序特定数值的功能 System.Buffers.SearchValues<T>
对ReadOnlySpan或 进行高效搜索,其内只有一个Contains(T)方法
注意: ReadOnlySequence
System.Buffers.ReadOnlySequence 只读序列,不连续的Memory
内存片段 ReadOnlySequenceSegment
在我们读取数据的过程,很多时候会出现如下场景:不知道数据实际大小, 一次性申请大量内存开销太大, 此时我们往往会使用动态内存的方案,通过链表的方式串联起来,从而形成逻辑意义上的数据流。可以理解成非连续内存的ReadOnlyMemory<T>组成的链表
, 我们在使用它时可以看成一个虚拟的ReadOnlyMemory
其中System.IO.Pipelines
它扮演了重要的角色.
扩展阅读: https://www.cnblogs.com/jionsoft/p/13676277.html
ArraySegment数组段(分隔一维数组的一部分)
它表示数组的一段连续区域或片段
。当你需要处理数组的不同部分而不复制整个数组
时,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
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
System.IO.Pipelines: C#高性能IO
C# 使用Pipelines处理Socket数据包
github.com/kk-cpp/KeenNet
编写高效的代码,你应该了解Array、Memory、ReadOnlySequence
最后更新于 2024-05-11 17:45:49 并被添加「」标签,已有 720 位童鞋阅读过。
本站使用「署名 4.0 国际」创作共享协议,可自由转载、引用,但需署名作者且注明文章出处
此处评论已关闭