内存和跨度pt.1

从.NET Core 2.0和.NET Framework 4.5开始,我们可以使用新的数据类型: SpanMemory 。 要使用它们,您只需要安装System.Memory nuget软件包:


PM> Install-Package System.Memory

这些数据类型值得注意,因为CLR团队通过将这些数据类型嵌入到.NET Core 2.1+ JIT编译器的代码中来实现它们的特殊支持,做了出色的工作。 这些是哪种数据类型,为什么它们值得一整章呢?


如果我们谈论使这些类型出现的问题,我应该列举其中三个。 第一个是非托管代码。


语言和平台以及存在与非托管代码一起工作的方法都已经存在多年。 那么,如果前者基本上已经存在很多年,为什么还要发布另一个API来处理非托管代码呢? 要回答这个问题,我们应该了解我们以前缺少的东西。


本章由作者和专业翻译员共同译自俄语。 您可以帮助我们将俄语或英语翻译成任何其他语言,主要是中文或德语。

另外,如果您想感谢我们,最好的方法是在github上给我们加星号或分支存储库 github / sidristij / dotnetbook

平台开发人员已经尝试为我们促进非托管资源的使用。 他们为导入的方法实现了自动包装,并在大多数情况下自动进行封送处理。 这里也属于stackalloc ,在有关线程堆栈的章节中已提到。 但是,正如我所看到的,第一批C#开发人员来自C ++世界(我的情况),但是现在他们从更高级的语言转移了(我认识一个以前使用JavaScript编写的开发人员)。 这意味着人们对非托管代码和C / C +构造越来越怀疑,而对于汇编程序则更是如此。


结果,项目包含的不安全代码越来越少,并且对平台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一词,它暗示可能出了问题,而且还有一个unsafe关键字和byte* buffer = stackalloc byte[s_readBufferSize]; 改变我们态度的行(特别是byte* )。 这是引发您思考的触发因素:“没有其他方法可以做到这一点”吗? 因此,让我们更深入地进行心理分析:您为什么会这样想? 一方面,我们使用语言构造,并且此处提供的语法与C ++ / CLI相距甚远,C ++ / CLI允许任何操作(甚至插入纯汇编代码)。 另一方面,这种语法看起来很不正常。


开发人员想到的第二个问题是字符串和char []类型的不兼容。 尽管从逻辑上讲字符串是一个字符数组,但是不能将字符串转换为char []:只能创建一个新对象并将字符串的内容复制到数组中。 引入此不兼容性是为了在存储方面优化字符串(不存在只读数组)。 但是,开始使用文件时会出现问题。 如何阅读它们? 作为字符串还是数组? 如果选择数组,则不能使用某些设计用于字符串的方法。 读为字符串呢? 可能太长了。 如果需要解析,那么应该为原始数据类型选择哪种解析器:您并不总是希望手动解析它们(整数,浮点数,以不同的格式给出)。 我们有许多行之有效的算法,可以更快,更有效地进行,不是吗? 但是,此类算法通常使用仅包含基本类型本身的字符串。 因此,存在一个难题。


第三个问题是算法所需的数据很少会在从某个源读取的数组的一部分内形成连续的实体数据切片。 例如,如果文件或数据是从套接字读取的,则其中的一部分已经由算法处理,然后是必须由我们的方法处理的一部分数据,然后是尚未处理的数据。 理想情况下,我们的方法只需要设计该方法的数据。 例如,解析整数的方法对包含某些单词且其中某些位置带有预期数字的字符串不满意。 此方法只需要一个数字,不需要其他任何东西。 或者,如果我们传递整个数组,则需要指出例如距数组开头的数字的偏移量。


 int ParseInt(char[] input, int index) { while(char.IsDigit(input[index])) { // ... index++; } } 

但是,这种方法很差,因为该方法会获取不必要的数据。 换句话说, 该方法被称为不是针对其设计的上下文 ,并且必须解决一些外部任务。 这是一个糟糕的设计。 如何避免这些问题? 作为一种选择,我们可以使用ArraySegment<T>类型,该类型可以访问数组的一部分:


 int ParseInt(IList<char>[] input) { while(char.IsDigit(input.Array[index])) { // ... index++; } } var arraySegment = new ArraySegment(array, from, length); var res = ParseInt((IList<char>)arraySegment); 

但是,我认为这在逻辑和性能降低方面都太多了。 与对数组执行的相同操作相比, ArraySegment的设计较差,并且对元素的访问速度降低了7倍。


那么我们如何解决这些问题呢? 我们如何使开发人员重新使用非托管代码,并为他们提供一个统一,快速的工具来处理异构数据源:数组,字符串和非托管内存。 必须给他们一种自信,使他们确信自己不会在不知不觉中犯错。 有必要为他们提供一种不会降低性能的本地数据类型但能够解决所列问题的工具。 Span<T>Memory<T>类型正是这些工具。


跨度<T>,ReadOnlySpan <T>


Span类型是一种用于处理数据数组的一部分内或其值的子范围内的数据的工具。 与数组一样,它允许读取和写入该子范围的元素,但有一个重要的约束:您只能为数组的临时工作获得或创建Span<T> ,只需要调用一组方法即可。 但是,为了获得一般性的了解,让我们比较一下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(); 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, out var res3)) { Console.WriteLine(res3); } ----- 234 234 234 

这意味着Span<T>是用于统一处理托管和非托管内存的方法的工具。 它可以确保在垃圾收集期间处理此类数据时的安全性。 也就是说,如果具有不受管资源的内存范围开始移动,那将是安全的。


但是,我们应该如此兴奋吗? 我们能早点实现吗? 例如,对于托管数组,毫无疑问:您只需要在另一个类中包装一个数组即可(例如,长期存在的[ArraySegment]( https://referencesource.microsoft.com/#mscorlib/system/ arraysegment.cs,31 ))因此提供了类似的界面,就是这样。 此外,您可以对字符串执行相同操作-它们具有必要的方法。 同样,您只需要将字符串包装为相同类型并提供使用它的方法。 但是,要以一种类型存储字符串,缓冲区和数组,则需要在单个实例中保留对每个可能变量的引用(显然只有一个活动变量)。


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

或者,根据体系结构,您可以创建实现统一接口的三种类型。 因此,不可能在这些数据类型之间创建与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托管类型。


本章由作者和专业翻译员共同译自俄语。 您可以帮助我们将俄语或英语翻译成任何其他语言,主要是中文或德语。

另外,如果您想感谢我们,最好的方法是在github上给我们加星号或分支存储库 github / sidristij / dotnetbook

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


All Articles