在本文中,将会介绍 C# 7.2 中引入的新类型:Span 和 Memory,文章深入研究 Span
本文所有代码用例在 .NET 6.0 下运行。
.NET 中,开发者能够使用的三种内存类型,分别是:
.NET Core 2.1 中新引入的类型包括:
开发者可能经常需要在应用程序中处理大量数据,例如字符串处理在任何应用程序中都是至关重要的,因此开发者必须遵循推荐的实践以避免不必要的分配。开发者可以使用不安全的代码块和指针直接操作内存,但是这种方法有相当大的风险,指针操作容易出现错误,如溢出、空指针访问、缓冲区溢出和悬空指针。如果 bug 只影响堆栈或静态内存区域,那么它将是无害的,但是如果它影响关键的系统内存区域,则可能导致应用程序崩溃。
因此,出现了 Span
Span
C# 新版本添加了 Span
这些新类型在 System.Memory 命名空间中,适用于需要处理大量数据或希望避免不必要的内存分配(例如在使用缓冲区时)的高性能场景。与在 GC 堆上分配内存的数组类型不同,这些新类型提供了对任意托管或本机内存的连续区域的抽象,而不需要在 GC 堆上分配内存。
译者注:因为它们都是 struct,会被分配到栈中。
Span
Span
Span 类型表示驻留在托管堆、堆栈甚至非托管内存中的连续内存块,如果创建一个基元类型的数组(使用 stackalloc 创建),它将在堆栈上分配,并且不需要垃圾回收来管理其生存期。Span
以下是一目了然的 Span
开发者可以将 Span 与下列任一项一起使用
可以转换为 Span
开发者可以将以下所有内容转换为 ReadOnlySpan
Span
public readonly ref struct Span { internal readonly ByReference _pointer; private readonly int _length; //Other members}
因为 Span 定义时,是 public readonly ref struct Span
开发者可以在这里查看 struct Span
Span
Span 的使用方式与数组相同,但是与数组不同,它可以引用堆栈内存,即堆栈上分配的内存、托管内存和本机内存。这为开发者提供了一种简单的方法来利用以前只有在处理非托管代码时才能获得的性能改进。
若要创建空的 Span,可以使用 Span.Empty 属性:
Span span = Span.Empty;
下面的代码片段演示如何在托管内存中创建 Byte 数组,然后从中创建 span 实例。
var array = new byte[100];var span = new Span(array);
下面是如何在堆栈中分配一块内存并使用 Span 指向它:
Span span = stackalloc byte[100];
下面的代码片段显示了如何使用字节数组创建 Span、如何将整数存储在字节数组中以及如何计算存储的所有整数的总和。
var array = new byte[100];var span = new Span(array);byte data = 0;for (int index = 0; index < span.Length; index++) span[index] = data++;int sum = 0;foreach (int value in array) sum += value;
下面的代码片段从本机内存(非托管内存)创建一个 Span:
var nativeMemory = Marshal.AllocHGlobal(100);Span span;unsafe{ span = new Span(nativeMemory.ToPointer(), 100);}
现在可以使用下面的代码片段在 Span 指向的内存中存储整数,并显示存储的所有整数的总和:
byte data = 0;for (int index = 0; index < span.Length; index++) span[index] = data++;int sum = 0;foreach (int value in span) sum += value;Console.WriteLine (#34;The sum of the numbers in the array is {sum}");Marshal.FreeHGlobal(nativeMemory);
还可以使用 stackalloc 关键字在堆栈内存中分配 Span,如下所示:
byte data = 0;Span span = stackalloc byte[100];for (int index = 0; index < span.Length; index++) span[index] = data++;int sum = 0;foreach (int value in span) sum += value;Console.WriteLine (#34;The sum of the numbers in the array is {sum}");
需要开启不安全代码设置。
切片允许将数据视为逻辑块,然后可以以最小的资源开销处理这些逻辑块。Span
int[] array = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 } ;Span slice = new Span(array, 2, 3);
作为 Span
开发者可以使用 Span
foreach (int i in slice) Console.WriteLine(#34;{i} ");
执行前面的代码片段时,分片数组中的整数将显示在控制台上,如图2所示。
ReadOnlySpan
下面的代码片段说明了如何使用 ReadOnlySpan 在 C# 中切割字符串的一部分:
ReadOnlySpan readOnlySpan = "This is a sample data for testing purposes.";int index = readOnlySpan.IndexOf(' ');var data = ((index < 0) ? readOnlySpan : readOnlySpan.Slice(0, index)).ToArray();
Memory
以下是 Memory 的定义:
public struct Memory { void* _ptr; T[] _array; int _offset; int _length; public Span Span => _ptr == null ? new Span(_array, _offset, _length) : new Span(_ptr, _length);}
除了包含 Span
当需要修改或处理 Memory
尽管 Span
与 ReadOnlySpan
现在请参考下面的字符串,其中包含由空格字符分隔的国家名称。
string countries = "India Belgium Australia USA UK Netherlands";var countries = ExtractStrings("India Belgium Australia USA UK Netherlands".AsMemory());
通过提取字符串的方法提取每个国家的名称,如下所示:
public static IEnumerable> ExtractStrings(ReadOnlyMemory c){ int index = 0, length = c.Length; for (int i = 0; i < length; i++) { if (char.IsWhiteSpace(c.Span[i]) || i == length) { yield return c[index..i]; index = i + 1; } }}
开发者可以调用上述方法,并使用以下代码片段在控制台窗口中显示国家名称:
var data = ExtractStrings(countries.AsMemory());foreach(var str in data) Console.WriteLine(str);
使用 Span 和 Memory 类型的主要优点是提高了性能。开发者可以通过使用 stackalloc 关键字来分配堆栈上的内存,该关键字分配一个未初始化的块,该块是 T[size]类型的实例。如果开发者的数据已经在堆栈上,则不需要这样做,但是对于大型对象,这样做很有用,因为以这种方式分配的数组只有在其作用域持续存在时才存在。如果使用堆分配的数组,可以通过 Slice()这样的方法传递它们,并在不复制任何数据的情况下创建视图。
这里还有一些好处:
连续内存缓冲区是将数据保存在顺序相邻位置的内存块,换句话说,所有的字节在内存中都是相邻的。数组表示连续的内存缓冲区。
例如:
int[] values = new int[5];
上面示例中的五个整数将从第一个元素(值[0])开始,按顺序放置在内存中的五个位置。
与连续缓冲区不同,开发者可以使用非连续缓冲区来处理多个数据块并不相邻的情况,或者在使用非托管代码时使用非连续缓冲区,Span 和 Memory 类型是专门为非连续缓冲区设计的,并提供了使用它们的方便方法。
非连续的内存区域不能保证元素以任何特定的顺序存储,也不能保证元素在内存中紧密地存储在一起。非连续缓冲区(如 ReadOnlySequence (与段一起使用时))驻留在内存的单独区域中,这些区域可能分散在堆中,不能被单个指针访问。
例如,IEnumable 是非连续的,因为在开发者逐个枚举每个项之前,无法知道下一个项将在哪里。为了表示段之间的这些间隔,必须使用附加数据来跟踪每个段的开始和结束位置。
让作者们假设开发者正在使用一个不连续的缓冲区。例如,数据可能来自网络流、数据库调用或文件流。这些场景中的每一个都可以有多个大小不同的缓冲区。一个 ReadOnlySequence 实例可以包含一个或多个内存段,每个段可以有自己的 Memory 实例。因此,单个 ReadOnlySequence 实例可以更好地管理可用内存,并提供比许多串联内存实例更好的性能。
开发者可以使用 SequenceReader 类上的工厂方法 Create()以及 AsReadOnlySequence()等其他方法创建 ReadOnlySequence 实例。Create() 方法有几个重载,允许开发者传入 byte [] 或 ArraySegment、字节数组序列(IEnumable)或 IReadOnlyCollection /IReadOnlyList/IList / ICollection 字节数组集合(byte [])和 ArraySegment。
开发者现在知道 Span
int[] array = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };var readOnlySequence = new ReadOnlySequence<int>(array);var slicedReadOnlySequence = readOnlySequence.Slice(1, 5);
开发者也可以使用 ReadOnlyMemory
int[] array = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };ReadOnlyMemory<int> memory = array;var readOnlySequence = new ReadOnlySequence<int>(memory);var slicedReadOnlySequence = readOnlySequence.Slice(1, 5);
现在让作者们来谈谈一个现实中的场景,以及 Span
string[] logs = new string[]{ "a1K3vlCTZE6GAtNYNAi5Vg::05/12/2022 09:10:00 AM::http://localhost:2923/api/customers/getallcustomers", "mpO58LssO0uf8Ced1WtAvA::05/12/2022 09:15:00 AM::http://localhost:2923/api/products/getallproducts", "2KW1SfJOMkShcdeO54t1TA::05/12/2022 10:25:00 AM::http://localhost:2923/api/orders/getallorders", "x5LmCTwMH0isd1wiA8gxIw::05/12/2022 11:05:00 AM::http://localhost:2923/api/orders/getallorders", "7IftPSBfCESNh4LD9yI6aw::05/12/2022 11:40:00 AM::http://localhost:2923/api/products/getallproducts"};
请记住,开发者可以拥有数百万条日志记录,因此性能至关重要。这个示例只是从大量日志数据中提取的日志数据。每个行的数据由 HTTP 请求 ID、 HTTP 请求的 DateTime 和端点 URL 组成。现在假设开发者需要从这些数据中提取请求 ID 和端点 URL。
开发者需要一个高性能的解决方案。如果使用 String 类的 Substring 方法,就会创建许多字符串对象,这也会降低应用程序的性能。最好的解决方案是在这里使用 Span
是时候测量一下了。现在让作者们对 Span
目前为止还不错。下一步是安装必要的 NuGet 包。要将所需的包安装到项目中,右键单击解决方案并选择 Manage NuGet Packages for Solution... 。现在在搜索框中搜索名为 BenchmarkDotNet 的软件包并安装它。或者,开发者也可以在NuGet Package Manager 命令提示符下键入以下命令:
PM> Install-Package BenchmarkDotNet
现在让作者们研究一下如何对 Substring 和 Slice 方法的性能进行基准测试。使用清单1中的代码创建一个名为 BenchmarkPerformance 的新类。开发者应该注意在 GlobalSetup 方法中如何设置数据以及 GlobalSetup 属性的用法。
[MemoryDiagnoser][Orderer(BenchmarkDotNet.Order.SummaryOrderPolicy.FastestToSlowest)][RankColumn]public class BenchmarkPerformance{[Params(100, 200)] public int N; string countries = null; int index, numberOfCharactersToExtract; [GlobalSetup] public void GlobalSetup() { countries = "India, USA, UK, Australia, Netherlands, Belgium"; index = countries.LastIndexOf(",",StringComparison.Ordinal); numberOfCharactersToExtract = countries.Length - index; }}
现在,编写名为 Substring 和 Span 的两个方法,如清单2所示。前者使用 String 类的 Substring 方法检索最后一个国家名称,而后者使用 Slice 方法提取最后一个国家名称。
[Benchmark]public void Substring(){ for(int i = 0; i < N; i++) { var data = countries.Substring(index + 1, numberOfCharactersToExtract - 1); }}[Benchmark(Baseline = true)]public void Span(){ for(int i=0; i < N; i++) { var data = countries.AsSpan().Slice(index + 1, numberOfCharactersToExtract - 1); }}
class Program{ static void Main() { BenchmarkRunner.Run(); }}
若要执行基准测试,请将项目的编译模式设置为“发布”,并在项目文件所在的同一文件夹中运行以下命令:
dotnet run -c Release
下图显示了基准测试的执行结果。
如上一小节的图所示,在使用 Slice 方法提取字符串时,绝对没有分配。对于每个基准测试方法,都会生成一行结果数据。因为有两个基准测试方法,所以有两行基准测试结果数据。基准测试结果显示了平均执行时间、 Gen0集合和分配的内存。从基准测试结果中可以明显看出,Span 比 Substring 方法快7.5倍以上(译者图中的结果是9倍)。
Span
需要注意的是,类中不能有 Span
在本文中,作者研究了 Span
原作者:Joydip Kanjilal
原文地址:https://www.codemag.com/Article/2207031/Writing-High-Performance-Code-Using-SpanT-and-MemoryT-in-C
本文采用半译方式。
文章来源于面向云技术架构 ,作者痴者工良
留言与评论(共有 0 条评论) “” |