让你的程序性能起飞,Span<T>使用方法

作者:微信公众号:【架构师老卢】
5-3 16:21
33

概述:今天谈论的是,它已经讨论了几年了,因为它已经与C#7.2一起引入,并在.NET Core 2.1及更高版本的运行时中受支持。在本文中,我们将介绍一些如何使用的示例,并讨论为什么在编写下一行代码时应该考虑使用它。SpanTSpanT📖 什么SpanT?System.SpanT是 .NET 核心的新值类型。它支持对任意内存的连续区域的表示,无论该内存是与托管对象关联、由本机代码通过互操作提供还是位于堆栈上。在这样做的同时,它仍然提供具有类似阵列的性能特征的安全访问。🙄 是的,我也是,也有点困惑。让我们来分解一下!首先,它是一种类型,一种我可能会添加的值类型(C# 中有两种类型:引用类型和值类型

今天谈论的是,它已经讨论了几年了,因为它已经与C#7.2一起引入,并在.NET Core 2.1及更高版本的运行时中受支持。在本文中,我们将介绍一些如何使用的示例,并讨论为什么在编写下一行代码时应该考虑使用它。Span<T>Span<T>

📖 什么Span<T>?

System.Span<T>是 .NET 核心的新值类型。它支持对任意内存的连续区域的表示,无论该内存是与托管对象关联、由本机代码通过互操作提供还是位于堆栈上。在这样做的同时,它仍然提供具有类似阵列的性能特征的安全访问。🙄 是的,我也是,也有点困惑。让我们来分解一下!

首先,它是一种类型,一种我可能会添加的值类型(C# 中有两种类型:引用类型和值类型。引用类型的变量存储对其数据(对象)的引用,而值类型的变量直接包含其数据)。Span<T> 提供类型安全(即防止一种类型的对象窥视分配给另一个对象的内存)对内存的连续区域(**相邻、**下一个或一起)的访问。此内存可以位于堆、堆栈上,甚至可以由非托管内存组成。

Span<T> 提供对相邻内存区域的类型安全访问

开发人员通常不需要了解他们正在使用的库是如何实现的。但是,就 Span<T> 而言,至少对其背后的细节有一个基本的了解是值得的。正如我之前提到的,它的值类型,包含一个 ref 和一个长度,定义大致如下:

由于这个字段,我们可以通过引用( C 中的指针)传递一个值(对象、数组等),这样堆栈上就有一个 ref T。正因为如此,操作可以像对数组一样高效:索引到一个范围不需要计算来确定指针的开始及其开始偏移量,因为 ref 字段本身已经封装了两者。ref

由于此字段,我们可以通过引用传递值ref

因此,您还必须了解 span 只是底层内存的视图而不是实例化内存块的方法。 提供对内存的读写访问,并提供只读访问。因此,在同一数组上创建多个跨度会创建同一内存的多个视图。让我详细说明一下。Span<T>ReadOnlySpan<T>

假设您在堆的某个位置分配了一个字符串数组。您可以通过将 span 传递给 span 构造函数来围绕此字符串数组包装 span。这样做会将指针字段分配给数据开始的内存地址(数组的第 0 个元素),并将 length 字段设置为连续可访问元素的数量(在本例中为 4)

要进一步理解这一点,请考虑以下示例。让我们使用同一数组的切片创建两个跨度,将它们称为第一个视图和第二个视图。

访问此处复制

您可以看到 firstView 和 secondView 重叠,这不是问题,因为正如我之前提到的,Span<T> 只是底层内存的视图

我们可以将 Span 与:

  • 堆(托管对象)——例如数组、字符串
  • 堆栈(通过 stackalloc)
  • 本机/非托管 (P/Invoke)

它非常有用,因为我们可以简单地对现有的内存块进行切片并对其进行管理,而不必复制它并分配新内存。

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

  • 阵 列
  • 指针
  • 斯塔卡洛克
  • IntPtr

使用 _ReadOnlySpan<T>_我们可以转换上述所有内容和字符串。

好了,现在您了解了 Span<T> 的结构和基本实现,让我们继续进行优化。

🚀 如何优化?

考虑要求:我们需要一个方法,它接受一个数组并返回其 1/4 的元素,从中间元素开始。

想象一个没有 Span<T 的世界>如果我要写这篇文章,我会做这样的事情:return myArray.Skip(Size / 2).Take(Size / 4).ToArray();

访问此处复制

现在这行得通了,但我们需要将它与其他几个实现进行比较,因此为此,我们使用 BenchmarkDotNet,一个 .NET 的基准库。设计基准测试不是本文的范围,但它非常简单。阅读更多:

概述

创建新的控制台应用程序并安装 BenchmarkDotNet NuGet 包。我们支持: 项目:经典和现代...

benchmarkdotnet.org

在我们的示例中,我们有 3 种方法来做同样的事情,一种是早期实现,另一种是 Array.Copy(),最后是 Span<T>(使用 AsSpan 扩展方法,在目标数组的一部分上从指定位置到指定长度创建新的只读跨度)。

访问此处复制

我们用 Annotation 创建了一个类,并有一个方法来填充数组。方法带有注释,使它们成为基准测试,方法设置为 ,当您运行此控制台应用程序时,您需要将解决方案协构设置为 ,而不是 。BenchmarkDemo1 [MemoryDiagnoser]SetUp()[Benchmark]Original()BaselineReleaseDebug

好的,让我们谈谈结果。特别是摘要的_“均值_”和_“分配_”列。

您可以清楚地看到,对于所有三种尺寸,Span<T> 仅花费了大约 1 ns(纳秒),而其他方法花费的时间要长得多。另外,请注意 Span 的内存分配为 0。😮

让我们再举一个例子:给定一个格式为 “dd mm yyyy” 的字符串日期。 将其转换为 DateTime。

通常我会做这样的事情。

访问此处复制

让我们对 Span 做同样的事情。为此,我们将使用一个名为 ReadOnlySpan<T> 的 Span<T> 版本。由于 span 是内存的同步访问器,因此只读 span 是只读内存的访问器。

访问此处复制

现在,当我们运行此基准测试时,我们会看到相同的优化。

0 分配和平均值减少 25%。

这些基准测试的完整源代码可以在这里找到。

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

namespace SpanTDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            //BenchmarkRunner.Run<BenchmarkDemo1>();
            BenchmarkRunner.Run<BenchmarkDemo2>();
        }
    }

    [MemoryDiagnoser]
    public class BenchmarkDemo1
    {
        private int[] _myArray;

        [Params(100, 1000, 10000)]
        public int Size { get; set; }

        [GlobalSetup]
        public void SetUp()
        {
            _myArray = new int[Size];
            for (int index = 0; index < Size; index++)
            {
                _myArray[index] = index;
            }
        }

        [Benchmark(Baseline = true)]
        public int[] Original()
        {
            return _myArray.Skip(Size / 2).Take(Size / 4).ToArray();
        }

        [Benchmark]
        public int[] ArrayCopy()
        {
            var copy = new int[Size / 4];
            Array.Copy(_myArray, Size / 2, copy, 0, Size / 4);
            return copy;
        }

        [Benchmark]
        public Span<int> Span()
        {
            return _myArray.AsSpan().Slice(Size / 2, Size / 4);
        }
    }

    [MemoryDiagnoser]
    public class BenchmarkDemo2
    {
        private static readonly string _dateString = "01 05 1991";
        
        [Benchmark(Baseline = true)]
        public DateTime Original()
        {
            var day = _dateString.Substring(0, 2);
            var month = _dateString.Substring(3, 2);
            var year = _dateString.Substring(6);
            return new DateTime(int.Parse(year), int.Parse(month), int.Parse(day));
        }

        [Benchmark]
        public DateTime Span()
        {
            ReadOnlySpan<char> dateSpan = _dateString;
            var day = dateSpan.Slice(0, 2);
            var month = dateSpan.Slice(3, 2);
            var year = dateSpan.Slice(6);
            return new DateTime(int.Parse(year), int.Parse(month), int.Parse(day));
        }
    }
}

⚠️ Span<T> 的局限性

Span<T>是在堆栈上分配的 ref 结构,而不是在托管堆上分配的 ref 结构。Ref 结构类型有许多限制,以确保它们不能被提升到托管堆,包括它们不能被装箱(将值类型转换为类型或由此值类型实现的任何接口类型的过程),它们不能分配给 Object 类型的变量,或任何接口类型, 它们不能是引用类型中的字段,也不能跨边界使用。此外,对两个方法 Equals(Object)GetHashCode 的调用会引发 NotSupportedException。objectdynamicawaityield

因为它是仅限堆栈的类型,因此不适用于许多需要在堆上存储对缓冲区的引用的方案。例如,对于进行异步方法调用的例程,情况就是如此。对于此类方案,可以使用互补的 System.Memory<T>System.ReadOnlyMemory<T> 类型,这是将来讨论的另一个主题。

相关留言评论
昵称:
邮箱:
阅读排行