[C#] 在 C# 中使用 Span<T> 和 Memory<T> 编写高性能代码

20 篇文章 0 订阅

英文原文:https://www.codemag.com/Article/2207031/Writing-High-Performance-Code-Using-SpanT-and-MemoryT-in-C

在本文中,您将了解 C# 7.2 中引入的新类型:Span 和 Memory。我将深入研究 Span<T> 和 Memory<T> 并演示如何在 C# 中使用它们。

先决条件

如果您要使用本文中讨论的代码示例,您需要在系统中安装以下软件:

  • Visual Studio 2022
  • .NET 6.0
  • ASP.NET 6.0 Runtime

如果您的计算机上尚未安装 Visual Studio 2022,可以从此处下载:https://visualstudio.microsoft.com/downloads/。

.NET 支持的内存类型

Microsoft .NET 使您能够使用三种类型的内存,包括:

  • Stack内存:驻留在Stack中并使用 stackalloc 关键字进行分配
  • 托管内存:驻留在堆中,由GC管理
  • 非托管内存:驻留在非托管堆中,通过调用 Marshal.AllocHGlobal 或 Marshal.AllocCoTaskMem 方法进行分配

.NET Core 2.1 中新增的类型

.NET Core 2.1 中新引入的类型有:

  • System.Span:这以类型安全和内存安全的方式表示任意内存的连续部分。
  • System.ReadOnlySpan:这表示任意连续内存区域的类型安全和内存安全只读表示。
  • System.Memory:这代表连续的内存区域。
  • System.ReadOnlyMemory:与ReadOnlySpan类似,该类型表示一段连续的内存。但是,与 ReadOnlySpan 不同,它不是 ByRef 类型。

访问连续内存:Span和Memory

您可能经常需要在应用程序中处理大量数据。字符串处理在任何应用程序中都至关重要,因为您必须遵循建议的做法以避免不必要的分配。您可以使用不安全的代码块和指针来直接操作内存,但这种方法存在相当大的风险。指针操作很容易出现溢出、空指针访问、缓冲区溢出和悬空指针等错误。如果 bug 只影响堆栈或静态内存区域,那么它是无害的;但如果它影响关键的系统内存区域,则可能会导致您的应用程序崩溃。进入 Span<T> 和 Memory<T>。

Span<T> 和 Memory<T> 是 .NET 中新引入的。它们提供了一种类型安全的方式来访问任意内存的连续区域。 Span 和 Memory<T> 都是 system 命名空间的一部分,表示连续的内存块,没有任何复制语义。 Span<T>、Memory<T>、ReadOnlySpan 和 ReadOnlyMemory 类型已新添加到 C# 中,可以帮助您以安全且高性能的方式直接使用内存。

这些新类型是 System.Memory 命名空间的一部分,旨在用于需要处理大量数据或希望避免不必要的内存分配(例如使用缓冲区时)的高性能场景。与在 GC 堆上分配内存的数组类型不同,这些新类型提供了对任意托管或本机内存的连续区域的抽象,而无需在 GC 堆上进行分配。

Span<T> 和 Memory<T> 结构为数组、字符串或任何连续的托管或非托管内存块提供低级接口。它们的主要功能是促进微优化并编写低分配代码来减少托管内存分配,从而减轻垃圾收集器的压力。它们还允许切片或处理数组、字符串或内存块的一部分,而无需复制原始内存块。 Span<T> 和 Memory<T> 在高性能领域非常有用,例如 ASP.NET 6 请求处理管道。

Span简介

Span<T>(之前称为 Slice)是 C# 7.2 和 .NET Core 2.1 中引入的一种值类型,开销几乎为零。它提供了一种类型安全的方式来处理连续的内存块,例如:

  • Arrays and subarrays
  • Strings and substrings
  • Unmanaged memory buffers

Span 类型表示驻留在托管堆、栈甚至非托管内存中的连续内存块。如果创建基本类型的数组,它将在栈上分配,并且不需要垃圾回收来管理其生命周期。 Span<T> 能够指向分配在栈或堆上的一块内存。但是,由于 Span<T> 被定义为引用结构,因此它应该仅驻留在堆栈上。

以下是 Span<T> 的特性概览:

  • 值类型
  • 低或零开销
  • 高性能
  • 提供内存和类型安全

您可以将 Span 与以下任意一项一起使用:

  • Arrays
  • Strings
  • Native buffers

可以转换为 Span<T> 的类型列表为:

  • Arrays
  • Pointers
  • IntPtr
  • stackalloc

您可以将以下所有内容转换为 ReadOnlySpan<T>:

  • Arrays
  • Pointers
  • IntPtr
  • stackalloc
  • string

Span<T> 是仅栈类型;准确地说,它是 ByRef 类型。因此,Span 既不能装箱,也不能显示为仅堆栈类型的字段,也不能在泛型参数中使用。但是,您可以使用 Span 来表示返回值或方法参数。请参阅下面给出的代码片段,它说明了 Span 结构的完整源代码:

public readonly ref struct Span<T> 
{
    internal readonly
    ByReference<T> _pointer;
    private readonly int _length;
    //Other members
}

您可以在此处查看 struct Span<T> 的完整源代码:https://github.com/dotnet/corefx/blob/master/src/Common/src/CoreLib/System/Span.cs。

Span<T> 源代码显示它基本上包含两个只读字段:一个 native 指针和一个表示 Span 包含的元素数量的 length 属性。

Span 的使用方式与数组相同。然而,与数组不同的是,它可以指向栈内存,即在栈上分配的内存、托管内存和本机内存。这为您提供了一种简单的方法来利用以前仅在处理非托管代码时才可用的性能改进。

以下是在 System 命名空间中声明 Span<T> 的方式。

public readonly ref struct Span<T>

要创建空 Span,可以使用 Span.Empty 属性:

Span<char> span = Span<char>.Empty;

下面的代码片段展示了如何在托管内存中创建一个字节数组,然后从中创建一个 span 实例。

var array = new byte[100];
var span = new Span<byte>(array);

C# 中的Span编程

以下是如何在栈中分配一块内存并使用 Span 指向它:

Span<byte> span = stackalloc byte[100];

以下代码片段展示了如何使用字节数组创建 Span、在字节数组内存储整数以及计算所有存储整数的总和。

var array = new byte[100];
var span = new Span<byte>(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;

以下代码片段从 native 内存创建一个 Span:

var nativeMemory = Marshal.AllocHGlobal(100);
Span<byte> span;
unsafe
{
    span = new Span<byte>(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 ($"The sum of the numbers in the array is {sum}");
Marshal.FreeHGlobal(nativeMemory);

还可以使用 stackalloc 关键字在栈内存中分配一个 Span,如下所示:

byte data = 0;
Span<byte> 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 ($"The sum of the numbers in the array is {sum}");

请记住在项目中启用不安全代码的编译。为此,请右键单击您的项目,单击“属性”,然后选中“不安全代码”复选框,如图 1 所示。

在这里插入图片描述
图 1:为您的项目打开不安全编译以启用不安全代码。

Span 和 数组

切片使数据能够被视为逻辑块,然后可以以最小的资源开销进行处理。 Span<T> 可以包装整个数组,并且由于它支持切片,因此您可以使其指向数组中的任何连续区域。以下代码片段显示如何使用 Span<T> 指向数组中由三个元素组成的切片。

int[] array = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 } ;
Span<int> slice = new Span<int>(array, 2, 3);

Slice 方法有两个重载可用作 Span<T> 结构的一部分,允许基于索引创建切片。这允许 Span<T> 数据被视为一系列逻辑块,这些逻辑块可以单独处理或根据数据处理管道的各个部分的需要进行处理。

您可以使用 Span<T> 来包装整个数组。因为它支持切片,所以它不仅可以指向数组的第一个元素,还可以指向数组内任何连续的元素范围。

foreach (int i in slice)
    Console.WriteLine($"{i} ");

当您执行前面的代码片段时,切片数组中存在的整数将显示在控制台上,如图 2 所示。

在这里插入图片描述
图 2:控制台窗口中显示的切片数组中存在的整数

Span 和 ReadOnlySpan

ReadOnlySpan<T> 实例通常用于引用数组项或数组块。与数组相反,ReadOnlySpan<T> 实例可以引用本机内存、托管内存或堆栈内存。 Span<T> 和 ReadOnlySpan<T> 都提供连续内存区域的类型安全表示。虽然 Span<T> 提供对内存区域的读写访问,但 ReadOnlySpan<T> 提供对内存段的只读访问。

以下代码片段说明了如何使用 ReadOnlySpan 在 C# 中对字符串的一部分进行切片:

ReadOnlySpan<char> 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<T> 是一种引用类型,表示内存的连续区域并具有长度,但不一定从索引 0 开始,并且可以是另一个内存中的许多区域之一。 Memory 表示的内存甚至可能不是您的进程自己的,因为它可能是在非托管代码中分配的。Memory 对于表示非连续缓冲区中的数据非常有用,因为它允许您将它们视为单个连续缓冲区而无需复制。

以下是 Memory<T> 的定义方式:

public struct Memory<T> 
{
    void* _ptr;
    T[]   _array;
    int   _offset;
    int   _length;

    public Span<T> Span => _ptr == null ? new Span<T>(_array, _offset, _length) : new Span<T>(_ptr, _length);
}

除了 Span<T> 之外,Memory<T> 还为任何连续缓冲区(无论是数组还是字符串)提供了安全且可切片的视图。与 Span<T> 不同,它没有仅限栈的约束,因为它不是类似引用的类型。因此,您可以将其放置在堆上、在集合中使用它或与 async-await 一起使用、将其保存为字段或将其装箱,就像任何其他 C# 结构一样。

当您需要修改或处理 Memory<T> 引用的缓冲区时,Span<T> 属性允许您获得高效的索引功能。相反,Memory<T> 是比 Span<T> 更通用、更高级的交换类型,具有名为 ReadOnlyMemory<T> 的不可变、只读对应类型。

尽管 Span<T> 和 Memory<T> 都表示连续的内存块,但与 Span<T> 不同,Memory<T> 不是引用结构。因此,与 Span<T> 相反,您可以在托管堆上的任何位置拥有 Memory<T>。因此,Memory<T> 中没有与 Span<T> 中相同的限制。您可以使用 Memory<T> 作为类字段,并跨越 await 和 yield 边界。

ReadOnlyMemory

与 ReadOnlySpan<T> 类似,ReadOnlyMemory<T> 表示对连续内存区域的只读访问,但与 ReadOnlySpan<T> 不同,它不是 ByRef 类型。

现在参考以下字符串,其中包含由空格字符分隔的国家/地区名称。

string countries = "India Belgium Australia USA UK Netherlands";
var countries = ExtractStrings("India Belgium Australia USA UK Netherlands".AsMemory());

ExtractStrings 方法提取每个国家/地区名称,如下所示:

public static IEnumerable
<ReadOnlyMemory <char>> ExtractStrings(ReadOnlyMemory<char> 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 的优点

使用 Span 和 Memory 类型的主要优点是提高性能。您可以使用 stackalloc 关键字在堆栈上分配内存,该关键字会分配一个未初始化的块,该块是类型 T[size] 的实例。如果您的数据已经在栈上,则这不是必需的,但对于大型对象,这很有用,因为以这种方式分配的数组仅在其作用域持续时存在。如果您使用的是堆分配的数组,则可以通过 Slice() 等方法传递它们并创建视图,而无需复制任何数据。

以下是更多优点:

  • 它们减少了垃圾收集器的分配数量。它们还减少了数据副本的数量,并提供了一种更有效的方法来同时处理多个缓冲区。
  • 它们允许您编写高性能代码。例如,如果您有一大块内存需要分成更小的块,请使用 Span 作为原始内存的视图。这允许您的应用程序直接访问原始缓冲区中的字节,而无需进行复制。
  • 它们允许您直接访问内存而无需复制它。当使用 native 库或与其他语言互操作时,这尤其有用。
  • 它们允许您消除性能至关重要的紧密循环中的边界检查(例如加密或网络数据包检查)。
  • 它们允许您消除与通用集合(例如 List)相关的装箱和拆箱成本。
  • 它们可以通过使用单一数据类型(Span)而不是两种不同的类型(Array 和 ArraySegment)来编写更容易理解的代码。

连续和非连续内存缓冲区

连续内存缓冲区是在连续相邻位置保存数据的内存块。换句话说,所有字节在内存中都是相邻的。数组代表连续的内存缓冲区。例如:

int[] values = new int[5];

上例中的五个整数将被放置在内存中从第一个元素 (values[0]) 开始的五个连续位置。

与连续缓冲区相反,在存在多个数据块彼此不相邻的情况下或在使用非托管代码时,可以使用非连续缓冲区。 Span 和 Memory 类型是专门为非连续缓冲区设计的,并提供了使用它们的便捷方法。

内存的非连续区域不能保证元素以任何特定顺序存储或它们在内存中紧密存储。非连续缓冲区,例如 ReadOnlySequence(与segments一起使用时),驻留在单独的内存区域中,这些区域可能分散在堆中,并且无法通过单个指针访问。

例如,IEnumerable 是不连续的,因为在您单独枚举每一项之前,无法知道下一项将在哪里。为了表示segments之间的这些间隙,您必须使用附加数据来跟踪每个段的开始和结束位置。

不连续缓冲区:ReadOnlySequence

假设您正在使用不连续的缓冲区。例如,数据可能来自网络流、数据库调用或文件流。这些场景中的每一个都可以有多个不同大小的缓冲区。单个 ReadOnlySequence 实例可以包含一个或多个内存段,并且每个段可以有自己的 Memory 实例。因此,单个 ReadOnlySequence 实例可以更好地管理可用内存,并提供比许多串联 Memory 实例更好的性能。

您可以使用 SequenceReader 类上的工厂方法 Create() 以及其他方法(例如 AsReadOnlySequence())创建 ReadOnlySequence 实例。 Create() 方法有多个重载,允许您传入 byte[] 或 ArraySegment、字节数组序列 (IEnumerable) 或字节数组 (byte[]) 和 ArraySegment 的 IReadOnlyCollection/IReadOnlyList/IList/ICollection 集合。

您现在知道 Span<T> 和 Memory<T> 提供对连续内存缓冲区(例如数组)的支持。 System.Buffers 命名空间包含一个名为 ReadOnlySequence<T> 的结构,它提供对不连续内存缓冲区的支持。以下代码片段说明了如何在 C# 中使用 ReadOnlySequence<T>:

int[] array = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
var readOnlySequence = new ReadOnlySequence<int>(array);
var slicedReadOnlySequence = readOnlySequence.Slice(1, 5);

还可以使用ReadOnlyMemory<T>,如下所示:

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<T> 和 Memory<T> 如何提供帮助。考虑以下字符串数组,其中包含从日志文件检索的日志数据:

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 请求的日期时间和端点 URL。现在假设您需要从此数据中提取请求 ID 和端点 URL。

您需要一个高性能的解决方案。如果您使用 String 类的 Substring 方法,将会创建许多字符串对象,并且也会降低应用程序的性能。最好的解决方案是在此处使用 Span<T> 以避免分配。解决方案是使用 Span<T> 和 Slice 方法,如下一节所示。

性能基准测试

是时候进行一些测量了。现在让我们对 Span<T> 结构与 String 类的 Substring 方法的性能进行基准测试。

在 Visual Studio 2022 中创建新的控制台应用程序项目

让我们创建一个控制台应用程序项目,您将使用它来进行性能基准测试。您可以通过多种方式在 Visual Studio 2022 中创建项目。启动 Visual Studio 2022 时,您将看到“开始”窗口。您可以选择“不使用代码继续”来启动 Visual Studio 2022 IDE 的主屏幕。

要在 Visual Studio 2022 中创建新的控制台应用程序项目:

  1. 启动 Visual Studio 2022 IDE。
  2. 在“创建新项目”窗口中,选择“控制台应用程序”,然后单击“下一步”继续。
  3. 将项目名称指定为 HighPerformanceCodeDemo,并在“配置新项目”窗口中指定创建该项目的路径。
  4. 如果您希望在同一目录中创建解决方案文件和项目,可以选择选中“将解决方案和项目放在同一目录中”复选框。单击“下一步”继续。
  5. 在下一个屏幕中,指定您想要用于控制台应用程序的目标框架。
  6. 单击“创建”以完成该过程。

您将在本文的后续部分中使用此应用程序。

安装 NuGet 包

到目前为止,一切都很好。下一步是安装必要的 NuGet 包。要将所需的包安装到项目中,请右键单击解决方案,然后选择管理解决方案的 NuGet 包…。现在在搜索框中搜索名为 BenchmarkDotNet 的包并安装它。或者,您可以在 NuGet 包管理器命令提示符处键入如下所示的命令:

PM> Install-Package BenchmarkDotNet

Span 性能基准测试

现在让我们研究一下如何对 Substring 和 Slice 方法的性能进行基准测试。使用清单 1 中的代码创建一个名为 BenchmarkPerformance 的新类。您应该注意 GlobalSetup 方法中如何设置数据以及 GlobalSetup 属性的用法。

//清单 1:设置基准数据
[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 方法提取最后一个国家/地区名称。

//清单 2:Substring 和 Span 方法
[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);
    }
}

清单 3 中提供了 BenchmarkPerformance 类的完整源代码供您参考。

//清单 3:完整的源代码
[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;
    }

    [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);
        }
    }
}

执行基准测试

在 Program.cs 文件中编写以下代码来运行基准测试:

using HighPerformanceCodeDemo;
using System.Runtime.InteropServices;
class Program
{
    static void Main(string[] args)
    {
        BenchmarkRunner.Run<BenchmarkPerformance>();
    }
}

要执行基准测试,请将项目的编译模式设置为 Release,并在项目文件所在的同一文件夹中运行以下命令:

dotnet run -p HighPerformanceCodeDemo.csproj -c Release

图 3 显示了基准测试的执行结果。
在这里插入图片描述
图 3:Span(Slice)与 substring 性能的基准测试

解读基准测试结果

正如您在图 3 中看到的,当您使用 Slice 方法提取字符串时,绝对没有分配。对于每个基准测试方法,都会生成一行结果数据。因为有两种基准方法,所以有两行基准结果数据。基准测试结果显示平均执行时间、Gen0 集合和分配的内存。从基准测试结果可以明显看出,Span 比 Substring 方法快 7.5 倍以上。

Span 的限制

Span<T> 仅限栈,这意味着它不适合在堆上存储对缓冲区的引用,如在执行异步调用的例程中。它不是在托管堆中分配,而是在堆栈上分配,并且不支持装箱以防止升级到托管堆。您不能将 Span<T> 用作泛型类型,但可以将其用作 ref 结构中的字段类型。您不能将 Span<T> 分配给动态、对象或任何其他接口类型类型的变量。您不能将 Span<T> 用作引用类型中的字段,也不能跨await 和yield 边界使用它。此外,由于 Span<T> 不继承 IEnumerable,因此不能将 LINQ 与它一起使用。

请务必注意,类中不能有 Span<T> 字段、创建 Span<T> 数组或装箱 Span<T> 实例。请注意,Span<T> 和 Memory<T> 均未实现 IEnumerable<T>。因此,您将无法对其中任何一个使用 LINQ 操作。但是,您可以利用 SpanLinq 或 NetFabric.Hyperlinq 来绕过此限制。

结束语

在本文中,我研究了 Span<T> 和 Memory<T> 的功能和优点以及如何在应用程序中实现它们。我还讨论了一个现实场景,其中 Span<T> 可用于提高字符串处理性能。请注意,Span<T> 比 Memory<T> 更通用且性能更好,但它并不能完全替代它。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值