Skip to content

如何使用 C# 中的 Span 来提升应用程序性能

Published: at 12:00 AM

如何使用 C# 中的 Span 来提升应用程序性能

摘要

在本文中,我们将学习如何在 C# 中使用 Span 来替换字符串和集合,以提升我们应用程序的性能

原文地址:How to Use Span in C# to Improve Application Performance


在软件开发中,性能始终是一个重要因素。这不只是框架开发者必须考虑的问题。当 .NET 团队发布了 Span<> 结构时,它使得开发者能够正确使用时提升应用性能。在本文中,我们将了解 C# 中的 Span,它是如何实现的,以及我们如何使用它来提升性能。

要下载本文的源代码,你可以访问我们的 GitHub 仓库

让我们开始吧。

理解 C# 中的 Span

首先,让我们来看看 Span<> 并了解它是如何在 .NET 中实现的。我们将看到为什么使用 Span 编码虽然受限,却能提高性能。

Span<> 是连续任意内存区域的无分配表示。Span<> 被实现为一个包含对对象 Tref 和长度的 ref struct 对象。这意味着在 C# 中的 Span 将始终被分配到栈内存,而非堆内存。让我们考虑一下 Span<> 的简化实现:

public readonly ref struct Span<T>
{
    private readonly ref T _pointer;
    private readonly int _length;
}

这个例子实现允许我们看到 span 的主要组成部分。这有一个指向类型为 T 的堆上对象的引用和一个长度。

使用 Span<> 会带来性能的提升,因为它们总是分配在栈上。由于垃圾回收不必频繁暂停执行以清理堆上没有引用的对象,应用程序运行得更快。暂停应用程序来回收垃圾始终是一个昂贵的操作,应尽可能避免。Span<> 操作可以像操作数组一样高效。索引到 span 中不需要计算索引的内存地址。

C# 中 Span 的另一个实现是 ReadOnlySpan<>。它是一个结构体,与 Span<> 完全相同,但其索引器返回的是 readonly ref T,而不是 ref T。这使我们能够使用 ReadOnlySpan<> 来表示如 String 这样的不可变数据类型。

Span 可以使用其他值类型,如 intbyteref structsboolenum。Span 不能使用类型如 objectdynamicinterfaces

Span 的限制

Span 的实现限制了其在代码中的使用,但相反地,它提供了 span 的有用属性。

编译器将引用类型对象分配在堆上,这意味着 我们不能将 span 作为引用类型中的字段使用。 更具体地说,ref struct 对象不能像其他值类型对象那样被装箱。出于同样的原因,Lambda 表达式也不能使用 span。Span 在异步编程中也不能跨越 awaityield 边界。

在所有情况下,Span 都不适当。因为我们使用 Span 在栈上分配内存,我们必须记住栈内存比堆内存少。 我们在选择使用 Span 还是字符串时必须考虑这一点。

如果我们想在异步编程中使用类似 Span 的类,我们可以利用 Memory<>ReadOnlyMemory<>。我们可以从数组创建一个 Memory<> 对象并像我们看到的那样切片,我们可以用 Span 做。一旦我们能同步运行代码,我们就可以从 Memory<> 对象获取一个 span。

如何使用 ReadOnlySpan 代替 String

首先,让我们讨论一下我们如何在操作 strings 时使用 ReadOnlySpan<> 来获得我们寻求的性能优势。

目标是尽可能多地使用 span 而不是 string。理想情况是我们有最少数量的字符串,我们可以使用 span 来操作。

让我们考虑一个必须按行解析字符串的例子:

public void ParseWithString()
{
    var indexPrev = 0;
    var indexCurrent = 0;
    var rowNum = 0;
    foreach (char c in _hamletText)
    {
        if (c == '\n')
        {
            indexCurrent += 1;
            var line = _hamletText.Substring(indexPrev, indexCurrent - indexPrev);
            if (line.Equals(Environment.NewLine))
                rowNum++;
            indexPrev = indexCurrent;
            continue;
        }
        indexCurrent++;
    }
    Console.WriteLine($"Number of empty lines in a file: {rowNum}");
}

首先,让我们使用字符串来回顾这个例子。我们试图确定文件中有多少个空行。要解析的文本存储在 _hamletText 字符串变量中。我们迭代测试字符串中的每个 char。如果我们找到一个新行字符 \n,我们使用 Substring() 来创建包含文本行的新字符串。如果那一行是空的,我们就增加我们的计数器。这里的关键是 Substring() 会在堆上创建一个 string。垃圾收集器将花时间销毁这些字符串。

现在,使用 ReadOnlySpan<> 进行同样的解析过程:

public void ParseWithSpan()
{
    var hamletSpan = _hamletText.AsSpan();
    var indexPrev = 0;
    var indexCurrent = 0;
    var rowNum = 0;
    foreach (char c in hamletSpan)
    {
        if (c == '\n')
        {
            indexCurrent += 1;
            var slice = hamletSpan.Slice(indexPrev, indexCurrent - indexPrev);
            if (slice.Equals(Environment.NewLine, StringComparison.OrdinalIgnoreCase))
                rowNum++;
            indexPrev = indexCurrent;
            continue;
        }
        indexCurrent++;
    }
    Console.WriteLine($"Number of empty lines in a file: {rowNum}");
}

这里的过程相同,除了我们没有为每行创建额外的字符串。我们通过调用 AsSpan() 方法将文本字符串转换为 ReadOnlySpan<>。此外,我们使用 Slice() 方法而不是 Substring()Slice() 返回表示子字符串的 ReadOnlySpan<>。在这种情况下,没有分配到堆上的东西。

回收内存中的对象会直接影响应用程序性能地暂停执行。将这些原则应用到包括日志子系统的大型生产软件中,我们可以看到这些性能增强是如何累积的。

ReadOnlySpan<> 包括许多我们与 String 相关联的熟悉函数。我们可以使用 Contains()EndsWith()StartsWith()IndexOf()LastIndexOf()ToString()Trim()

如何使用 Span 代替集合

由于 spans 可以表示内存的连续部分,这意味着我们可以使用它们来操作 arrays 和其他集合类型。

首先,让我们考虑使用数组的例子:

int[] arr = new[] { 0, 1, 2, 3 };
Span<int> intSpan = arr;
var otherSpan = arr.AsSpan();

我们可以看到 C# 提供了从 T[]Span<T> 的隐式转换,但我们也能够在数组上调用 AsSpan()。就像 ReadOnlySpan<> 一样,Span<> 提供了以无分配方式操作 span 的熟悉函数。

现在让我们看一个与 collectionList<> 类似的例子:

List<int> intList = new() { 0, 1, 2, 3 };
var listSpan = CollectionsMarshal.AsSpan(intList);

我们可以看到,使用集合的 span 不像使用数组那样简单。在这种情况下,我们必须使用 CollectionMarshal.AsSpan() 方法来获取集合作为 span。要使用 marshal,我们必须导入 System.Runtime.InteropServices

使用基准测试

在本节中,我们将评估运行 StringReadOnlySpan<> 示例代码的基准测试结果。

让我们讨论从这个基准比较中我们可能期望收到的结果。正如我们所知,使用 spans 通过不将新对象分配到堆上并使用更少的内存来提高性能。我们期望看到运行时间和分配的内存改善。

现在,让我们看看结果:

|          Method |     Mean |    Error |   StdDev |   Gen 0 | Allocated |
|---------------- |---------:|---------:|---------:|--------:|----------:|
|   ParseWithSpan | 23.55 us | 0.109 us | 0.102 us |       - |         - |
| ParseWithString | 36.12 us | 0.064 us | 0.054 us | 16.7236 |  70,192 B |

从数据中我们可以看到,ParseWithString() 的平均时间为 36.12μs 并分配了 70.2kB (70,192B) 的内存。ParseWithSpan() 的平均时间为 23.55μs,并且没有分配额外的内存。结果正如我们所期待的。Span 函数运行得更快并分配了更少的内存。

结论

在这篇文章中,我们已经介绍了 spans 的实现,为什么使用它们能提高性能,以及如何在我们的代码中使用 spans。需要注意的是,.NET 开发团队正在积极支持 spans 在他们的库中的使用,并使它们的使用对开发者尽可能简单。.NET 团队已经为 DateTimeTimeSpanInt32GUIDStringBuilderSystem.Random 等许多其他类支持 Span<>。了解如何使用 Span<> 很重要,因为它们将在未来的 .NET 代码中更加普遍,并可能在某些情况下成为标准。