[DotNetBook]跨度:新的.NET数据类型

在这篇文章中,我将继续发表一系列文章,其结果将是一本关于.NET CLR和.NET的著作的书(该书已经准备好约200页,因此欢迎来到本文结尾以获取链接)。


语言和平台都存在了很多年:一直以来,有许多用于处理非托管代码的工具。 那么,如果实际上存在很多年了,为什么现在推出下一个用于处理非托管代码的API? 为了回答这个问题,足以了解以前缺少的内容。


该平台的开发人员试图使用不受管理的资源来帮助我们改善开发的日常生活:这些是导入方法的自动包装器。 编组,在大多数情况下会自动运行。 这也是stackallloc指令,将在线程堆栈这一章中进行讨论。 但是,就我而言,如果使用C#的早期开发人员来自C ++世界(就像我所做的那样),那么现在他们来自高级语言(例如,我认识一个来自JavaScript的开发人员)。 这是什么意思? 这意味着人们越来越怀疑不受管理的资源和构造,它们在本质上与C / C ++甚至与Assembler相似。


注意事项


在哈布雷(Habré)上发表的这一章没有更新,可能有点过时了。 因此,请转到原始文本以获取更多最新文本:



由于这种态度,项目中不安全代码的内容越来越少,并且对平台本身的API的信任也越来越高。 通过查看开放存储库中stackalloc构造的使用,可以很容易地验证这一点:可以忽略不计。 但是,如果您使用任何使用它的代码:


类Interop.ReadDir
/src/mscorlib/shared/Interop/Unix/System.Native/Interop.ReadDir.cs


 unsafe { // s_readBufferSize is zero when the native implementation does not support reading into a buffer. byte* buffer = stackalloc byte[s_readBufferSize]; InternalDirectoryEntry temp; int ret = ReadDirR(dir.DangerousGetHandle(), buffer, s_readBufferSize, out temp); // We copy data into DirectoryEntry to ensure there are no dangling references. outputEntry = ret == 0 ? new DirectoryEntry() { InodeName = GetDirectoryEntryName(temp), InodeType = temp.InodeType } : default(DirectoryEntry); return ret; } 

显然不受欢迎的原因。 不用看代码就可以看一下,自己回答一个问题:您相信吗? 我可以认为答案是否定的。 然后回答另一个:为什么? 答案将是显而易见的:除了看到Dangerous一词,它在某种程度上暗示可能有问题,影响我们态度的第二个因素是行byte* buffer = stackalloc byte[s_readBufferSize]; ,更具体地说,是byte* 。 这张唱片是任何人的触发点,因此我的脑海中浮现出这样的想法:“什么,不能做不同的事情或什么?” 然后,让我们多看一些心理分析:为什么会出现这种想法? 一方面,我们使用语言构造,并且此处提出的语法与C ++ / CLI相去甚远,C ++ / CLI允许您执行任何操作(包括纯汇编程序上的插入操作),另一方面,它看起来很不正常。


那是什么问题呢? 如何使开发人员重回非托管代码的怀抱? 必须给他们一种镇定的感觉,使他们不会由于无知而偶然犯错。 那么,为什么要引入 Span<T>Memory<T>呢?


跨度[T],ReadOnlySpan [T]


Span类型表示某个数据数组的一部分,是其值的子范围。 同时,与数组的情况一样,允许使用此范围的元素进行写入和读取。 但是,出于超频和一般的理解,让我们比较为Span类型实现的数据类型,并研究其引入的可能目的。


您要讨论的第一类数据是常规数组。 对于数组,使用Span将如下所示:


  var array = new [] {1,2,3,4,5,6}; var span = new Span<int>(array, 1, 3); var position = span.BinarySearch(3); Console.WriteLine(span[position]); // -> 3 

如本例所示,首先,我们创建一个特定的数据数组。 之后,我们创建一个Span (或子集),该Span (或子集)引用数组本身,允许其代码仅使用初始化期间指定的值范围。


在这里,我们看到了此数据类型的第一个属性:它创建了一些上下文。 让我们根据上下文来发展我们的想法:


 void Main() { var array = new [] {'1','2','3','4','5','6'}; var span = new Span<char>(array, 1, 3); if(TryParseInt32(span, out var res)) { Console.WriteLine(res); } else { Console.WriteLine("Failed to parse"); } } public bool TryParseInt32(Span<char> input, out int result) { result = 0; for (int i = 0; i < input.Length; i++) { if(input[i] < '0' || input[i] > '9') return false; result = result * 10 + ((int)input[i] - '0'); } return true; } ----- 234 

如我们所见, Span<T>引入了对特定内存访问的抽象,用于读取和写入。 这给了我们什么? 如果我们回想起可以基于什么进行Span操作,那么我们就回想起非托管资源和行:


 // Managed array var array = new[] { '1', '2', '3', '4', '5', '6' }; var arrSpan = new Span<char>(array, 1, 3); if (TryParseInt32(arrSpan, out var res1)) { Console.WriteLine(res1); } // String var srcString = "123456"; var strSpan = srcString.AsSpan().Slice(1, 3); if (TryParseInt32(strSpan, out var res2)) { Console.WriteLine(res2); } // void * Span<char> buf = stackalloc char[6]; buf[0] = '1'; buf[1] = '2'; buf[2] = '3'; buf[3] = '4'; buf[4] = '5'; buf[5] = '6'; if (TryParseInt32(buf.Slice(1, 3), out var res3)) { Console.WriteLine(res3); } ----- 234 234 234 

也就是说, Span<T>是用于处理内存的统一工具:托管和非托管,这保证了垃圾回收期间处理此类数据的安全性:如果托管数组的内存区域开始移动,则用于对我们来说将是安全的。


但是,值得这么高兴吗? 所有这些都可以实现吗? 例如,如果我们谈论托管数组,那么毫无疑问:将数组包装在另一个类中,提供类似的接口,就可以完成了。 而且,可以对字符串执行类似的操作:它们具有必要的方法。 同样,只需将字符串包装为完全相同的类型并提供使用它的方法。 另一件事是,为了以一种类型存储字符串,缓冲区或数组,您将不得不通过在单个副本中存储指向每个可能选项的链接来进行大量修改(当然,只有一个是活动的):


 public readonly ref struct OurSpan<T> { private T[] _array; private string _str; private T * _buffer; // ... } 

或者,如果您从体系结构开始,那么请执行三种继承单个接口的类型。 事实证明,为了使工具在受managed这些数据类型之间成为统一的接口,同时保持最佳性能,除了Span<T>别无他法。


进一步,为了继续讨论,在Span方面有什么ref struct ? 这些恰好是“结构,它们仅在堆栈上”,我们在采访中经常听到。 这意味着该数据类型只能通过堆栈,而无权进入堆。 因此, Span是一种ref结构,是一种上下文数据类型,它提供方法,但不提供内存中的对象。 由此,为了他的理解,我们必须继续。


从这里我们可以制定Span类型和与其关联的只读类型ReadOnlySpan的定义:


Span是一种数据类型,它提供了一个用于处理异构类型的数据数组的单一接口,以及将该数组的子集转移到另一种方法的能力,因此,无论上下文的深度如何,对原始数组的访问速度都是恒定且尽可能高的。

实际上:如果我们有类似以下代码的内容:


 public void Method1(Span<byte> buffer) { buffer[0] = 0; Method2(buffer.Slice(1,2)); } Method2(Span<byte> buffer) { buffer[0] = 0; Method3(buffer.Slice(1,1)); } Method3(Span<byte> buffer) { buffer[0] = 0; } 

那么对源缓冲区的访问速度将尽可能快:您不是在使用托管对象,而是在使用托管指针。 即 不是使用.NET托管类型,而是封装在托管外壳程序中的不安全类型。


跨度[T]的示例


一个人的安排如此频繁,以至于直到他获得一定的经验之前,通常往往无法最终理解为什么需要工具。 因此,由于我们需要一定的经验,因此我们来看一些示例。


ValueStringBuilder


在算法上最有趣的示例之一是ValueStringBuilder类型,该类型埋在mscorlib的肠道中,并且由于某些原因,像许多其他有趣的数据类型一样,都使用internal修饰符进行标记,这意味着如果不用于研究mscorlib源代码,我们将讨论这种出色的优化方法永远不会知道。


StringBuilder系统类型的主要缺点是什么? 当然,这是其本质:他本人以及他所基于的内容(这是一个char[]字符数组)都是引用类型。 这意味着至少有两件事:我们仍然(尽管有一点)加载一堆,第二件事-我们增加了处理器高速缓存中未命中的机会。


我对StringBuilder的另一个问题是小字符串的形成。 即 结果行中的“给牙”将较短:例如,少于100个字符。 如果格式比较短,就会出现性能问题:


  $"{x} is in range [{min};{max}]" 

与通过StringBuilder手动生成相比,此记录有多糟糕? 答案远非总是那么明显:这完全取决于形成的位置:该方法将被调用的频率。 毕竟,第一个string.Format为内部StringBuilder分配内存,这将创建一个字符数组(SourceString.Length + args.Length * 8),如果在数组形成过程中发现未猜到长度,则将创建另一个StringBuilder来形成延续,从而形成一个简单的连接列表。 结果,将有必要返回生成的行:这是另一个副本。 浪费和浪费。 现在,如果我们可以摆脱将要形成的字符串的第一个数组放在堆上的话,那就太好了:我们肯定会摆脱一个问题。


看一下mscorlib的类型:


类ValueStringBuilder
/ src / mscorlib /共享/系统/文本/ ValueStringBuilder


  internal ref struct ValueStringBuilder { //           private char[] _arrayToReturnToPool; //     private Span<char> _chars; private int _pos; //    ,       public ValueStringBuilder(Span<char> initialBuffer) { _arrayToReturnToPool = null; _chars = initialBuffer; _pos = 0; } public int Length { get => _pos; set { int delta = value - _pos; if (delta > 0) { Append('\0', delta); } else { _pos = value; } } } //   -       public override string ToString() { var s = new string(_chars.Slice(0, _pos)); Clear(); return s; } //       //     :   public void Insert(int index, char value, int count) { if (_pos > _chars.Length - count) { Grow(count); } int remaining = _pos - index; _chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count)); _chars.Slice(index, count).Fill(value); _pos += count; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Append(char c) { int pos = _pos; if (pos < _chars.Length) { _chars[pos] = c; _pos = pos + 1; } else { GrowAndAppend(c); } } [MethodImpl(MethodImplOptions.NoInlining)] private void GrowAndAppend(char c) { Grow(1); Append(c); } //   ,     //         //            //           [MethodImpl(MethodImplOptions.NoInlining)] private void Grow(int requiredAdditionalCapacity) { Debug.Assert(requiredAdditionalCapacity > _chars.Length - _pos); char[] poolArray = ArrayPool<char>.Shared.Rent(Math.Max(_pos + requiredAdditionalCapacity, _chars.Length * 2)); _chars.CopyTo(poolArray); char[] toReturn = _arrayToReturnToPool; _chars = _arrayToReturnToPool = poolArray; if (toReturn != null) { ArrayPool<char>.Shared.Return(toReturn); } } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void Clear() { char[] toReturn = _arrayToReturnToPool; this = default; // for safety, to avoid using pooled array if this instance is erroneously appended to again if (toReturn != null) { ArrayPool<char>.Shared.Return(toReturn); } } //  :       private void AppendSlow(string s); public bool TryCopyTo(Span<char> destination, out int charsWritten); public void Append(string s); public void Append(char c, int count); public unsafe void Append(char* value, int length); public Span<char> AppendSpan(int length); } 

此类的功能与其哥哥StringBuilder相似,但是它具有一个有趣且非常重要的功能:它是一个重要的类型。 即 完全按值存储和传输。 而最新的ref类型修饰符(分配给类型声明的签名)告诉我们,此类型还有一个附加限制:它有权仅在堆栈上。 即 将其实例输出到class字段将导致错误。 为什么所有这些下蹲? 要回答这个问题,请看一下StringBuilder类,我们刚刚描述了其本质:


StringBuilder类 /src/mscorlib/src/System/Text/StringBuilder.cs


 public sealed class StringBuilder : ISerializable { // A StringBuilder is internally represented as a linked list of blocks each of which holds // a chunk of the string. It turns out string as a whole can also be represented as just a chunk, // so that is what we do. internal char[] m_ChunkChars; // The characters in this block internal StringBuilder m_ChunkPrevious; // Link to the block logically before this block internal int m_ChunkLength; // The index in m_ChunkChars that represent the end of the block internal int m_ChunkOffset; // The logical offset (sum of all characters in previous blocks) internal int m_MaxCapacity = 0; // ... internal const int DefaultCapacity = 16; 

StringBuilder是一个类,其中包含指向字符数组的链接。 即 创建字符串时,实际上至少会创建两个对象:StringBuilder本身和至少16个字符的字符数组(顺便说一句,这就是为什么指定字符串的估计长度如此重要的原因:其构造将通过生成一个由16个字符组成的数组的单连接列表的过程。 ) 在我们讨论ValueStringBuilder类型的上下文中,这意味着什么:默认情况下不存在容量,因为 它从外部获取内存,并且它本身是重要的类型,并迫使用户为堆栈上的字符分配缓冲区。 结果,整个类型实例连同其内容一起被推入堆栈,并且这里的优化问题得以解决。 堆上没有内存分配? 堆上的性能下降没有问题。 但是您告诉我:为什么不总是使用ValueStringBuilder(或其自写版本:它是内部的,我们无法访问)? 答案是:您需要查看要解决的问题。 结果字符串的大小是否已知? 它会有一定的已知最大长度吗? 如果答案是肯定的,并且字符串的大小没有超出合理范围,则可以使用有意义的StringBuilder版本。 否则,如果我们希望排长队,我们将改用常规版本。


ValueListBuilder


我要特别注意的第二种数据类型是ValueListBuilder类型。 它是为需要在短时间内创建元素集合并立即将其提供给某种算法进行处理的情况而创建的。


同意:该任务与ValueStringBuilder任务非常相似。 是的,它的解决方法非常相似:


文件ValueListBuilder.cs


坦率地说,这种情况很普遍。 但是,在以另一种方式解决此问题之前,我们创建了一个List ,将其填充数据并丢失了链接。 如果经常调用该方法,则会出现一个悲惨的情况: List类的许多实例都挂在堆上,并且与它们关联的数组也挂在堆上。 现在,此问题已解决:不会创建其他对象。 但是,与ValueStringBuilder ,它仅适用于Microsoft程序员:类具有internal修饰符。


使用条款和条件


为了最终理解新数据类型的本质,您需要通过编写一些东西或者更好的使用它的更多方法来“玩转”它。 但是,现在可以了解基本规则:


  • 如果您的方法将处理某些传入数据集而不更改其大小,则可以尝试在Span类型处停止。 如果没有修改此缓冲区,则为ReadOnlySpan类型;否则,为0。
  • 如果您的方法适用于字符串,计算一些统计信息或解析字符串,则您的方法必须接受ReadOnlySpan<char> 。 这是必须的:这是一条新规则。 毕竟,如果您接受字符串,则可以迫使某人为您创建子字符串
  • 如果在该方法的工作中需要用数据组成一个较短的数组(例如,最大10Kb),则可以使用Span<TType> buf = stackalloc TType[size]轻松组织这样的数组。 但是,当然,TType应该仅是有意义的类型,因为 stackalloc仅适用于有意义的类型。

在其他情况下,值得仔细研究一下Memory或使用经典数据类型。


跨度如何运作


另外,我想谈谈Span的工作原理以及它的杰出之处。 还有一点要讨论:数据类型本身分为两个版本:.NET Core 2.0+和其他所有版本。


Span.Fast.cs,.NET Core 2.0文件


 public readonly ref partial struct Span<T> { ///    .NET    internal readonly ByReference<T> _pointer; ///      private readonly int _length; // ... } 

文件??? [反编译]


 public ref readonly struct Span<T> { private readonly System.Pinnable<T> _pinnable; private readonly IntPtr _byteOffset; private readonly int _length; // ... } 

事实是, 大型 .NET Framework和.NET Core 1 *没有经过特殊修改的垃圾收集器(与.NET Core 2.0+版本相反),因此被迫将其他指针拖到:缓冲区的开头,工作。 也就是说,事实证明Span内部与.NET平台的托管对象一样以非托管方式工作。 看一下该结构的第二个版本的内部:有三个字段。 第一个字段是对被管理对象的引用。 第二个是从该对象的开头开始的偏移量(以字节为单位),以获取数据缓冲区的开头(在行中,它是具有char字符的缓冲区,在数组中,这是具有数组数据的缓冲区)。 最后,第三个字段是此缓冲区一个接一个地堆叠的元素数。


例如,将Span作业用于字符串:


文件coreclr :: src / System.Private.CoreLib / shared / System / MemoryExtensions.Fast.cs


 public static ReadOnlySpan<char> AsSpan(this string text) { if (text == null) return default; return new ReadOnlySpan<char>(ref text.GetRawStringData(), text.Length); } 

其中string.GetRawStringData()如下:


字段定义文件coreclr :: src / System.Private.CoreLib / src / System / String.CoreCLR.cs


GetRawStringData定义文件coreclr :: src / System.Private.CoreLib /共享/ System / String.cs


 public sealed partial class String : IComparable, IEnumerable, IConvertible, IEnumerable<char>, IComparable<string>, IEquatable<string>, ICloneable { // // These fields map directly onto the fields in an EE StringObject. See object.h for the layout. // [NonSerialized] private int _stringLength; // For empty strings, this will be '\0' since // strings are both null-terminated and length prefixed [NonSerialized] private char _firstChar; internal ref char GetRawStringData() => ref _firstChar; } 

即 事实证明,该方法直接在行内,而ref char规范允许您跟踪行内的GC非托管链接,并在GC操作期间将其与行一起移动。


数组也发生同样的情况:创建Span时,JIT中的一些代码将计算数组数据开头的偏移量,并Span该偏移量初始化Span 。 在本章中,我们了解了有关内存中对象结构的知识以及如何计算字符串和数组的偏移量。


跨度[T]作为返回值


尽管与关联的所有田园诗Span,它从方法中返回还是有逻辑但出乎意料的限制。如果您看下面的代码:


 unsafe void Main() { var x = GetSpan(); } public Span<byte> GetSpan() { Span<byte> reff = new byte[100]; return reff; } 

那么一切看起来都非常合乎逻辑并且很好。但是,值得将一条指令替换为另一条指令:


 unsafe void Main() { var x = GetSpan(); } public Span<byte> GetSpan() { Span<byte> reff = stackalloc byte[100]; return reff; } 

编译器将如何禁止这种指令。但是在写原因之前,我要求您自己猜测一下这样的设计会带来哪些问题。


因此,我希望您能思考,建立猜测和假设,甚至可能理解原因。如果是这样,我没有白费力气地在齿轮上的螺纹栈上涂上这一章。毕竟,在给出了已完成工作的方法的局部参数的链接之后,您可以调用另一个方法,等待其完成,然后通过x [0.99]读取其局部变量。


, , , , : CS8352 Cannot use local 'reff' in this context because it may expose referenced variables outside of their declaration scope : , , , .



Span<T> , . , use cases .


链接到整本书



Source: https://habr.com/ru/post/zh-CN418911/


All Articles