[C#] 使用 Span<T> 结构编写高性能 C# 简介

27 篇文章 3 订阅
20 篇文章 0 订阅

英文原文:https://nishanc.medium.com/an-introduction-to-writing-high-performance-c-using-span-t-struct-b859862a84e4

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

What is Span<T>?

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

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

在这里插入图片描述

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

开发人员通常不需要了解他们使用的库是如何实现的。然而,就 Span 而言,至少有必要对其背后的细节有一个基本的了解。正如我之前提到的,它是值类型,包含一个 ref 和一个长度,定义大致如下:
在这里插入图片描述
由于这个 ref 字段,我们可以通过引用(就像 C 中的指针)传递值(对象、数组等),这样堆栈上就有一个 ref T 。因此,操作可以与数组一样高效:索引到跨度不需要计算来确定指针的开头及其起始偏移量,因为 ref 字段本身已经封装了两者。
在这里插入图片描述
由于这个 ref 字段,我们可以通过引用传递值

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

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

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

在这里插入图片描述
您可以看到第一个视图和第二个视图重叠,这不是问题,因为正如我之前提到的, Span<T> 只是底层内存的一个视图。

在这里插入图片描述

我们可以使用 Span:

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

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

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

  • Arrays
  • Pointers
  • stackalloc
  • IntPtr

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

好了,现在你了解了 Span 的结构和基本实现,让我们继续优化。

如何优化?

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

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

在这里插入图片描述
现在这可以工作了,但我们需要将它与其他一些实现进行比较,因此为此目的我们使用 BenchmarkDotNet,它是 .NET 的基准库。设计基准测试不是本文的范围,但它相当简单。阅读更多:

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

在这里插入图片描述
我们创建了一个带有注释 [MemoryDiagnoser] 的 BenchmarkDemo1 类,并有一个 SetUp() 方法来填充数组。方法用 [Benchmark] 注释,使它们成为 Benchmark 测试,并将 Original() 方法设置为 Baseline ,当您运行此控制台应用程序时,您需要将解决方案配置设置为 Release,而不是 Debug。

好吧,我们来谈谈结果。特别是摘要的平均值和分配列。

在这里插入图片描述
您可以清楚地看到,对于所有三种大小,Span 仅花费了大约 1 ns(纳秒),而其他方法花费的时间要多得多。另外,请注意 Span 的内存分配为 0。

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

通常我会做这样的事情。

在这里插入图片描述
让我们对 Span 做同样的事情。为此,我们将使用名为 ReadOnlySpan<T> 的 Span<T> 版本。由于Span是存储器的同步访问器,所以 ReadOnlySpan 是只读存储器的访问器。

在这里插入图片描述
现在,当我们运行此基准测试时,我们会看到相同的优化。

在这里插入图片描述
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> 是一个引用结构,在堆栈上而不是在托管堆上分配。引用结构类型有许多限制,以确保它们不能提升到托管堆,包括不能装箱(将值类型转换为类型对象或由该值类型实现的任何接口类型的过程) ),它们不能分配给对象类型、动态类型或任何接口类型的变量,它们不能是引用类型中的字段,并且不能跨await 和yield 边界使用。此外,调用两个方法(Equals(Object) 和 GetHashCode)会引发 NotSupportedException。

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

感谢您来到这里,我们下一篇再见。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值