实际体验Span<T> 的惊人表现

本文对比了使用String、Span<T>、Regex及StringBuilder等不同方法过滤博客文章中的代码块的性能。结果显示Span<T>和Regex表现出色,尤其结合StringBuilder使用的Span<T>在时间和内存消耗上达到了最佳。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

最近做了一个过滤代码块功能的接口。就是获取一些博客文章做文本处理,然后这些博客文章的代码块太多了,很多重复的代码关键词如果被拿过来处理,那么会对文本的特征表示已经特征选择会有很大的影响。所以需要将这些代码块的部分给过滤掉。过滤起来很简单,就是找代码块的html 标记,然后将html标记之间的内容给删除就可以了。代码块的html标记一般都是<pre></pre>

我使用了String,Regex,StringBuilder,Span<T>这些不同的方法来实现这个功能,利用BenchMarks比较它们之间的性能差距。

BenchMarks

要对比不同代码之间的性能差距,还是不用StopWatch来计算消耗时间,这样简单的方法,而是使用BenchMarksDotNet包:一个专业的.net core下测试程序性能的工具包。

BenchMarksDotNetgithub地址

这里简短介绍下BenchMarksDotNet的使用:

首先新建一个需要测试的类:FilterCodeBlocks ,并在类中写上被测试的方法:FilterCodeBlockByString

 public class FilterCodeBlocks
 {

        public string FilterCodeBlockByString(string content)
        {
                return content;
        }
 }

然后新建一个类: FilterCodeBlocksBenchMark

using System;
using System.IO;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;

namespace QuickSortBenchMarks
{
    [RankColumn]
    [Orderer(SummaryOrderPolicy.FastestToSlowest)]
    [MemoryDiagnoser]
    public class FilterCodeBlocksBenchmarks
    {
            FilterCodeBlocks FilterCodeBlocks = new FilterCodeBlocks();
            [Benchmark]
           public void FilterByString()
           {

               FilterCodeBlocks.FilterCodeBlockByString(s);
           }
    }
}

最后在入口Progam.cs中 写上

    class Program
    {
        static void Main(string[] args)
         {
                var summary = BenchmarkRunner.Run<FilterCodeBlocksBenchmarks>();
         }
    }

执行dotnet build -c Release 然后 dotnet yourproject.dll 就可以看见BenchMarks测试效果.

铺垫好东西,现在开始进入正题。

使用 string

首先,直接用string 操作。由于测试博文可能会比较长,会有比较多的代码块。所以我的思路是,while(true) 去寻找代码块标记,并使用string 的寻址: indexOf() , 拼接:+= 和 剪切:Substring() 完成代码块的过滤。过程也很简单。 这只是解决问题的一种方法,这篇文章的目的不是寻找最优解决方法,而是比较发现使用不同的 "工具" 之间的巨大性能差距。

        private static string _startTag = "<pre";
        private static string _endTag = "</pre>";

        private static int _startTagLength => _startTag.Length;
        private static int _endTagLength => _endTag.Length;
        public FilterCodeBlocks()
        {

        }

        public string FilterCodeBlockByString(string content)
        {
            string result = "";
            while (true)
            {
                var startPos = content.IndexOf(_startTag, StringComparison.CurrentCulture);
                if (startPos == -1)
                    break;

                var content2 = content.Substring(startPos + _startTagLength, content.Length - startPos - _startTagLength);
                var endPos = content2.IndexOf(_endTag, StringComparison.CurrentCulture);
                result += content.Substring(0, startPos);
                content = content2.Substring(endPos + _endTagLength, content2.Length - endPos - _endTagLength);
            }
            result += content;
            return result;
        }

一开始选取了比较短的文本进行测试 ,可以直接写在程序中:

[RankColumn]
    [Orderer(SummaryOrderPolicy.FastestToSlowest)]
    [MemoryDiagnoser]
    public class FilterCodeBlocksBenchmarks
    {
        FilterCodeBlocks FilterCodeBlocks = new FilterCodeBlocks();
        public static string s = "<p>我们通过IndexWriterConfig 可以设置IndexWriter的属性," +
                            "已达到我们希望构建索引的需求,这里举一些属性,这些属性可以影响到IndexWriter写入索引的速度:" +
                            "</p>\n<div class=\"cnblogs_code\">\n<pre>IndexWriterConfig.setRAMBufferSizeMB" +
                            "(<span style=\"color: #0000ff;\">double</span><span style=\"color: #000000;\">);" +
                            "\nIndexWriterConfig.setMaxBufferedDocs(</span><span style=\"color: #0000ff;\">int</span><span " +
                            "style=\"color: #000000;\">);\nIndexWriterConfig.setMergePolicy(MergePolicy)</span></pre>\n</div>\n<p>" +
                            "setRAMBufferSizeMB()&nbsp;是设置";
        [Benchmark]
        public void FilterByString()
        {

            FilterCodeBlocks.FilterCodeBlockByString(s);
        }
   }

按照上述的方法,运行dll 得出 使用string 相关方法的性能。
948150-20190402182223391-1580804416.png

平均处理时间 48微秒 分配内存 1.41kb,看来效果也是不错的,我感觉上面的代码中方法也是大家都会经常使用的方法。

接下来 .NET Core 2.1的新特性: Span 隆重登场!

Span< T >

What is a Span< T >?

Span< T > : 结构体值类型 。相当于C++ 中的指针,它是一段连续内存的引用,也就是一段连续内存的首地址。有了Span< T >,我们就可以不在unsafe的代码块中写指针了。Span< char > 相对于 string 也就具有很大的性能优势。

举个栗子: string.Substring() 函数,实际上是在堆中额外创建了一个新的 string 对象,把字符 copy 过去,再返回这个对象的引用。而相对应的 Span< T > 的Slice() 函数则是直接在内存中返回子串的首地址引用,此过过程几乎不分配内存,并且十分高效。

后面的优化也是使用Span< T > 的Slice() 代替了 string 的SubString()

简单看下 Span< T > 的源码,就可以窥见 Span< T > 的奥秘:

 public readonly ref partial struct Span<T>
    {
        /// <summary>A byref or a native ptr.</summary>
        internal readonly ByReference<T> _pointer;
        /// <summary>The number of elements this Span contains.</summary>

        private readonly int _length;
         
        ....
        
        public Span(T[] array)
        {
            if (array == null)
            {
                this = default;
                return; // returns default
            }
            if (default(T) == null && array.GetType() != typeof(T[]))
                ThrowHelper.ThrowArrayTypeMismatchException();

            _pointer = new ByReference<T>(ref Unsafe.As<byte, T>(ref array.GetRawSzArrayData()));
            _length = array.Length;
         }
     }

Span< T > 内部主要就是一个ByReference< T > 类型的对象,实际上就是ref T: 一个类型的引用,它和C 的int* char* 如出一折。 Span < T > 也就是建立 ref 的基础上。

限定长度: _length ,就像 C 中定义指针,在使用前需要 malloc 或者 alloc 分配固定长度的内存。关于Span< T > 更多详细知识:

https://msdn.microsoft.com/en-us/magazine/mt814808.aspx

使用 Span< T > 优化

将上述 string 代码使用 Span< char > 优化一下

public string FilterCodeBlockBySpanAndToString(ReadOnlySpan<char> content)
        {
            string result = "";
            ReadOnlySpan<char> contentSpan2 = new ReadOnlySpan<char>();
            int startPos = 0;
            int endPos = 0;

            ReadOnlySpan<char> startTagSpan = _startTag.AsSpan();
            ReadOnlySpan<char> endTagSpan = _endTag.AsSpan();
            while (true)
            {
                startPos = content.IndexOf(startTagSpan);
                if (startPos == -1)
                    break;

                contentSpan2 = content.Slice(startPos + _startTagLength, content.Length - startPos - _startTagLength);
                endPos = contentSpan2.IndexOf(endTagSpan);
                result += content.Slice(0, startPos).ToString();
                content = contentSpan2.Slice(endPos + _endTagLength, contentSpan2.Length - endPos - _endTagLength);
            }
            result += content.ToString();
            return result;

        }

这里 ReadOnlySpan<char> 是 Span< char > 的只读类型。使用Slice 代替SubString 。上述代码我依然返回的是 string。为了得到 string,我不惜使用Span< T > 的ToString() 函数,在我印象中,这个操作会把Span 的优势给拉回起跑线。

接下来看测试结果:

948150-20190404083259818-944270157.png

真是大吃一惊,平均消耗时间,居然少了 48000 纳秒,Span< T > 只是 string 的不到百分之一消耗。内存消耗减少了一半

Span< T >果然名不虚传,正如前面所说的SubStringSlice 之间的性能差距。

Span< T > 的特色

虽然Span< T > 的性能十分出色 ,但是 string 有太多完善的接口,string 是为了简化你的代码让你更加舒服的使用字符串,所以牺牲了性能。因此 在对计算机消耗要求十分的严苛的情况下,尝试使用Span< T > ,大多数情况下,简短的string 已经能满足需求。我的认知下的Span< T >的特色:

  • Span< T >的定义方法多种多样,可以直接 ( i ) 像定义数组那样 : Span<int> a = new int[10]; ( ii ) 在构造函数中直接传入 数组(指针+长度)Span<T> a = new Span<T>(T[]),Span<T> a = new Span<T>(void*,length) ; ( iii )可以直接在栈中分配内存:Span<char> a = stackalloc char[10]; 在C# 8.0中才可以,这样的写法真是高大上。

  • Span< T > 只能存在于栈中,而不能放在堆中。因为 ( i ) GC 在堆中很难跟踪这些指针, ( ii ) 在堆中会出现多线程, 如果两个线程的两个Span< T >指向了同一个地址,那就糟了。
  • 可以使用 Memory< T > 代替 Span< T >在堆中使用。
  • 所有 string 的接口都可以用 Span< char > 来实现,这似乎又回到了原始的C语言时代。
  • Span < T > 有个兄弟叫 ReadOnlySpan< T > 。

到这里还不能结束Span< T >的性能评测。因为在大量字符串处理中还有个隐藏的实力派:正则表达式 Regex

正则表达式

如果我们使用正则表达式呢,它的性能会是如何呢?

正则表达式的实现:

  private static Regex _codeTag = new Regex("(<pre(.*?)>)(.|\n)*?(</pre>)", RegexOptions.Compiled);
  public string FilterCodeBlocByRegex(string content)
  {
            return _codeTag.Replace(content, string.Empty);
  }

真是简短的让人看着就舒服。正则表达式的长处是在大文本处理,所以我决定直接将字符串变成100篇博客的内容加在一起。下面就是测试结果:

948150-20190404083405853-1564995709.png

Incredible! 正则表达式 真的是一匹黑马,直逼Span< T >,时间消耗仅为10.68ms,内存消耗只有7.69MB。难得的是它的内存消耗也比Span< T >低。

为什么Regex会有这么好的表现呢?翻阅一下源码,原来如此!

private static string Replace(MatchEvaluator evaluator, Regex regex, string input, int count, int startat)
{
    ....
    Span<char> charInitSpan = stackalloc char[ReplaceBufferSize];
    var vsb = new ValueStringBuilder(charInitSpan);
}

.net core 2.2 中,Regex的 Replace 内部用了 Span< char > 重新实现。看来,正则表达式的高性能表现 和 Span 不无关系。

根据园友的评论,Regex 以前的版本,也是通过指针来进行操作,我也实验了 .net standard的Regex , 二者效率差不多。

Span < T > 很优秀,但是为了解决 string 的性能问题,C# 早早就有了 StringBuilder 。于是我让了字符串处理界的大师:StringBuilder, 来助 Span< T > 一臂之力。

StringBuilder + Span< T >

 public string FilterCodeBlockBySpanAndStringBuilder(ReadOnlySpan<char> content)
        {
            var result = new StringBuilder(content.Length);

            var contentSpan2 = new ReadOnlySpan<char>();
            var startPos = 0;
            var endPos = 0;

            var startTagSpan = _startTag.AsSpan();
            var endTagSpan = _endTag.AsSpan();
            while (true)
            {
                startPos = content.IndexOf(startTagSpan);
                if (startPos == -1)
                    break;

                contentSpan2 = content.Slice(startPos + _startTagLength, content.Length - startPos - _startTagLength);
                endPos = contentSpan2.IndexOf(endTagSpan);
                result.Append(content.Slice(0, startPos));
                content = contentSpan2.Slice(endPos + _endTagLength, contentSpan2.Length - endPos - _endTagLength);
            }
            result.Append(content);
            return result.ToString();

        }

将原先的 字符串拼接变成了 StringBuilder 的 append函数,而且减少了我心心念念的ToString()次数。在 .net core 2.2 中StringBuilder的内部也有 Span< T >的身影。

Append 函数可以直接接受Span< T >的参数。接下来看看武装到牙齿的Span< T >性能如何。

948150-20190404083428838-1413308332.png

unbelievable ! 使用 StringBuilder 的Span< T >时间消耗居然只有 867.1微妙,内存消耗只有1.7MB ,在各个方面都技压群雄。又是百分之一的消耗。

实际上 StringBuilder的内部操作字符串的 是一个 char 数组,它的 Apend 的性能如此之高,还是因为内部使用了指针。

            unsafe
                        {
                            fixed (char* valuePtr = value)
                            fixed (char* destPtr = &chunkChars[chunkLength])
                            {
                                string.wstrcpy(destPtr, valuePtr, valueLen);
                            }
              }

StringBuilder 只能支持字符串,但是Span< T >可是泛型的哦。不过,程序中最消耗CPU的大都是一些字符串的处理。

结语

在实际中体验了Span< T >的惊人表现。同时 .NET Core 在Span< T >加入之后,各个地方都有性能的提升,比如说Regex。 真是让开发者何其幸哉。

在Regex 中的源代码,我看到了一个 ValueStringBuilder 一个内部的结构体,只能在System/Text 的内部中使用。它是一个结构体!它的构造函数可以直接传入 Span< char >,我将它 copy 出来,代替StringBuilder , 时间消耗不分伯仲,但是内存消耗又减少了一半!。这应该是极致的性能表现。鉴于篇幅原因就不展开了。

可以在 这里 看到ValueStringBuilder,以及完整的代码。

转载于:https://www.cnblogs.com/dacc123/p/10644816.html

<think>嗯,我现在要介绍一下Span<T>和Memory<T>,这两个都是C#中的类型,对吧?让我先回想一下之前学过的东西。可能它们和内存操作有关,特别是处理数组或者内存块的时候。Span和Memory都是用来提供对内存区域的高效访问,同时保证安全性的类型。不过它们之间有什么区别呢?我需要仔细想想。 首先,Span<T>是什么?我记得Span是在C# 7.2引入的,属于System.Memory库的一部分。它代表了一个连续的内存区域,可以是数组、栈分配的内存或者非托管内存。Span<T>的一个关键特性是它可以在栈上分配,因此它不支持在堆上进行存储,比如不能作为类的字段,只能在栈上存在,比如方法内部的局部变量。这是因为Span是ref struct类型,而ref struct不能被装箱,所以只能在栈上。这样设计的好处是可以避免额外的堆分配,提高性能,尤其是在处理大量数据时,比如解析或处理字符串、文件等。 那Memory<T>呢?Memory<T>好像是为了解决Span<T>的一些限制而出现的。因为Span<T>是ref struct,所以不能存储在堆上,比如无法在异步方法中跨await使用,或者在类的字段中保存。这时候就需要Memory<T>,它属于普通的struct,可以存储在堆上,因此可以在更多地方使用。Memory<T>提供了类似的访问内存区域的能力,但更灵活,允许跨异步操作或者作为类的成员。当需要实际访问内存时,可以通过Memory<T>的Span属性获取Span<T>,然后在需要的时候进行操作。 举个例子,比如处理一个大的字节数组。如果使用Span<T>,可以高效地切片和访问数据,但由于只能在栈上使用,无法在异步方法中传递。而用Memory<T>的话,可以将它存储起来,在需要的时候再转换为Span进行操作。这样既保持了灵活性,又能在必要时高效处理。 不过,可能需要注意的是,使用Span和Memory的时候,要避免内存访问的问题,比如访问已经释放的内存。因为它们都是对现有内存的引用,而不是复制数据。因此,在正确性和生命周期管理上需要小心。另外,Span<T>支持的类型包括数组、栈分配内存和unmanaged内存,而Memory<T>可能更多用于托管内存,比如从数组或MemoryPool分配的内存。 总结一下,Span<T>适合在同步方法中对内存进行高效、临时的操作,而Memory<T>则更适合需要跨异步或存储在堆上的情况。两者都通过Slice方法进行切片,减少数据复制的开销,提升性能。不过,Span<T>的限制更多,使用场景更受限,而Memory<T>更灵活,但需要转换到Span才能进行实际的操作。 可能还有一些细节需要注意,比如Span<T>的性能优势,因为它避免了堆分配和GC的压力。而Memory<T>通过使用Span属性来获取Span<T>,这样在需要高性能操作的时候仍然可以高效处理。另外,在ASP.NET Core等高性能应用中,这两个类型被广泛使用,比如处理请求体中的字节流时,可以避免不必要的复制,提高吞吐量。 对了,还有一点,Memory<T>不能直接访问非托管内存,而Span<T>可以。所以当处理来自非托管代码的内存时,可能需要使用Span。另外,Memory<T>可能更适合在异步编程中使用,比如在某个方法中获取了Memory<T>,然后在另一个异步方法中处理它,这时候可以安全地传递,而不用担心像Span那样因为栈回收而导致的问题。 可能需要举一些代码示例来说明它们的用法。比如,用Span来操作数组的一部分: byte[] buffer = new byte[1024]; Span<byte> slice = buffer.AsSpan().Slice(10, 100); // 对slice进行操作,比如修改其中的数据,会影响原数组 而使用Memory<T>的话: Memory<byte> memory = new byte[1024]; // 在异步方法中传递memory await ProcessMemoryAsync(memory); ... private async Task ProcessMemoryAsync(Memory<byte> memory) { Span<byte> span = memory.Span; // 使用span进行操作 } 这样,在异步方法中,可以安全地使用Memory,并在需要时转换为Span进行操作。 总结来说,Span<T>和Memory<T>都是为了更高效地处理内存,减少复制,提高性能。它们的选择取决于具体的使用场景,是否需要跨异步、是否需要在堆上存储等因素。</think>在 C# 中,`Span<T>` 和 `Memory<T>` 是用于高效处理连续内存区域的两种类型,旨在减少数据复制并提升性能,同时确保内存安全。以下是它们的核心概念及区别: --- ### **1. Span\<T>** - **定位**:轻量级、高性能的 `ref struct`,直接表示连续内存(如数组、栈内存或非托管内存)。 - **特点**: - **栈绑定**:因是 `ref struct`,仅能存在于栈中,不能作为类的字段或跨异步操作使用。 - **零复制**:切片操作(如 `Slice()`)直接引用原数据,无复制开销。 - **同步场景**:适合同步代码中对内存的临时、快速访问(如解析字符串或处理数组)。 - **限制**: - 无法在堆中存储(如类字段、集合元素)。 - 不支持跨 `await` 异步操作(因栈可能被回收)。 #### **示例**: ```csharp byte[] buffer = new byte[1024]; Span<byte> span = buffer.AsSpan(); Span<byte> slice = span.Slice(start: 10, length: 100); // 零复制切片 slice.Fill(0xFF); // 修改直接作用于原数组 ``` --- ### **2. Memory\<T>** - **定位**:更灵活的 `struct`,可表示托管内存(如数组或 `MemoryPool` 分配的内存),适用于异步场景。 - **特点**: - **堆安全**:可存储在堆中(如类字段),支持跨异步操作。 - **按需转换**:通过 `.Span` 属性获取 `Span<T>`,仅在需要时进行高性能操作。 - **生命周期管理**:常与 `IMemoryOwner<T>` 和 `MemoryPool` 结合,显式释放内存。 - **限制**: - 无法直接访问非托管内存。 - 操作需先转换为 `Span<T>`。 #### **示例**: ```csharp Memory<byte> memory = new byte[1024]; await ProcessAsync(memory); async Task ProcessAsync(Memory<byte> memory) { Span<byte> span = memory.Span; // 转换为 Span 进行操作 span.Slice(10, 100).Fill(0xFF); } ``` --- ### **关键区别** | **特性** | **Span\<T>** | **Memory\<T>** | |-------------------|-------------------------------|--------------------------------| | **存储位置** | 仅栈 | 栈或堆 | | **跨异步支持** | ❌ 不可用 | ✔️ 可用 | | **作为类字段** | ❌ 禁止 | ✔️ 允许 | | **内存类型** | 数组、栈、非托管 | 主要托管内存(如数组) | | **性能开销** | 更低(直接操作) | 稍高(需转换 `Span`) | --- ### **使用场景建议** - **Span\<T>**:同步方法内的高频内存操作(如实时数据处理、解析)。 - **Memory\<T>**:需跨异步、存储到堆或生命周期较长的场景(如网络流处理)。 两者共同服务于高性能编程,通过避免不必要的数据复制,显著优化内存敏感型应用的效率(如网络协议处理、大型文件解析)。正确选择取决于作用域、存储需求和并发模型。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值