Span<T> —— .NET Core 高效运行的新基石

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

目录

Span 是什么鬼?

Span 是如何实现的?

Memory 又是什么鬼?

Span 和 Memory 是如何与 .NET 库集成的?

.NET 运行时有何变化?

C# 语言及其编译器有啥变化?

接下来呢?


假定我们想要写一个方法,来对内存中的数据进行排序。你可能会为该方法提供一个 T [ ] 数组参数。如果调用者想对整个数组进行排序,这个方法就没有问题,但是如果调用者只想要对数组的一部分进行排序呢?然后,你可能还会暴露一个带 offset 和 count 的重载。但是,如果你想让这个排序方法不仅支持数组,也支持本机代码(例如一个数组在堆栈中,我们只有一个指针和长度信息),你怎么编写这个排序方法,它可以在任意内存区域上运行,既支持完整的数组,也支持数组的子集,既能处理管数组,也能处理非托管指针?

再看一个例子。假如我们需要为 System.String 类写一个解析方法。你可能会编写一个接受字符串参数并操作该字符串的方法。但是,如果想对该字符串的子集进行操作,该怎么办? 我们可以用 String.Substring 来抽取,但这是一个昂贵的操作,涉及字符串分配和内存复制。我们像按照上个例子那样,取一个偏移量和一个计数,但是如果调用者没有字符串而是有一个 char []  会怎样?再或者,如果调用者有一个 char *(比如他们用 stackalloc 创建的来使用堆栈上的一些空间,或者是调用本机代码获得的结果),该怎么办呢?你怎么能在不强迫调用者进行任何分配或复制的情况下,使用你的方法,并对 string,char [] 和 char * 类型的输入同样有效?

在这两种情况下,你可以使用不安全的代码和指针,接受指针和长度作为参数。但是,这绕过了.NET 的核心安全保障,可能造成缓冲区溢出和访问冲突等问题,这些问题对于大多数.NET开发人员来说已成为过去。它还会产生额外的性能损失,例如需要在操作期间固定托管对象,以便指针保持有效。根据所涉及的数据类型,获取指针可能并不实际。

这个难题有一个答案,它的名字是 Span <T>。

Span<T> 是什么鬼?

System.Span<T> 是核心 .NET 库提供 的一个新的值类型。它代表着一块已知长度的连续内存块,这个内存块可以关联到一个托管对象,可以是通过互操作获取的本机码,也可以是栈的一部分。它提供了一个像访问数组那样安全地操作内存的方式。 它非常类似 T[] 或 ArraySegment,它提供安全的访问内存区域指针的能力。其实我理解它是.NET中操作(void*)指针的抽象封装,熟悉C/C++开发者应该更明白这意味着什么。

  Span的特点如下:

  1. 抽象了所有连续内存空间的类型系统,包括:数组、非托管指针、堆栈指针、fixed或pinned过的托管数据,以及值内部区域的引用;
  2. 支持CLR标准对象类型和值类型;
  3. 支持泛型;
  4. 支持GC,而不像指针需要自己来管理释放;

例如,我们可以通过一个数组创建一个 Span<T>:

var arr = new byte[10];
Span<byte> bytes = arr; // Implicit cast from T[] to Span<T>

由此,利用span 的 一个 Slice() 重载,我们可以轻易地创建一个 指向/代表 数组的一个子集的 span。

Span<byte> slicedBytes = bytes.Slice(start: 5, length: 2);
slicedBytes[0] = 42;
slicedBytes[1] = 43;
Assert.Equal(42, slicedBytes[0]);
Assert.Equal(43, slicedBytes[1]);
Assert.Equal(arr[5], slicedBytes[0]);
Assert.Equal(arr[6], slicedBytes[1]);
slicedBytes[2] = 44; // Throws IndexOutOfRangeException
bytes[2] = 45; // OK
Assert.Equal(arr[2], bytes[2]);
Assert.Equal(45, arr[2]);

Span 不仅仅可以用来代表子数组,它也可以用来指向栈上的数据。例如:

Span<byte> bytes = stackalloc byte[2]; // Using C# 7.2 stackalloc support for spans
bytes[0] = 42;
bytes[1] = 43;
Assert.Equal(42, bytes[0]);
Assert.Equal(43, bytes[1]);
bytes[2] = 44; // throws IndexOutOfRangeException

其实,span 可以用来指向任意的指针和长度区域,例如从非托管堆上分配的一段内存:

IntPtr ptr = Marshal.AllocHGlobal(1);
try
{
  Span<byte> bytes;
  unsafe 
  { 
     bytes = new Span<byte>((byte*)ptr, 1); 
  }
  bytes[0] = 42;
  Assert.Equal(42, bytes[0]);
  Assert.Equal(Marshal.ReadByte(ptr), bytes[0]);
  bytes[1] = 43; // Throws IndexOutOfRangeException
}
finally { Marshal.FreeHGlobal(ptr); }

Span<T> 中的索引器利用 C#7.0 中引入的称为 ref returns 的 C#语言特性。 索引器使用 “ref T” 返回类型声明,它提供类似索引到数组的语义,返回对实际存储位置的引用,而不是返回该位置的副本:

public struct Span<T> 
{  
    ref T _reference; 
    int _length;  
    public ref T this[int index] { get {...} }
    ...
}

public struct ReadOnlySpan<T> 
{  
    ref T _reference;    
    int _length;    
    public T this[int index] { get {...} }
    ...
}

ref return 索引器带来的影响可以通过与List<T> 的索引器(它不是 ref return)比较:

struct MutableStruct 
{ 
   public int Value;
}
...

Span<MutableStruct> spanOfStructs = new MutableStruct[1];
spanOfStructs[0].Value = 42;
Assert.Equal(42, spanOfStructs[0].Value);

var listOfStructs = new List<MutableStruct> { new MutableStruct() };
listOfStructs[0].Value = 42; // Error CS1612: the return value is not a variable

Span<T> 的一个变种是  System.ReadOnlySpan<T>,提供只读的访问。它与 Span<T> 不同的是,它的 索引器 利用了C# 7.2 的特性,返回的是 ref readonly T 而不是 ref T,这使得它能适用于 不可变的数据类型(immutable data types),例如 String。 ReadOnlySpan<T> 可以在不分配内存和拷贝字符串的情况下,实现对字符串的高效拆分

string str = "hello, world";
string worldString = str.Substring(startIndex: 7, length: 5); // Allocates
ReadOnlySpan<char> worldSpan = str.AsSpan().Slice(start: 7, length: 5); // No allocation
Assert.Equal('w', worldSpan[0]);
worldSpan[0] = 'a'; // Error CS0200: indexer cannot be assigned to

Span 还有其他优势。例如,span 支持 重新解释 的强制类型转换。你可以将Span <byte>转换为Span <int>(其中Span <int> 的第0个索引映射到Span <byte> 的前四个字节)。 这样,如果读取字节缓冲,则可以将其传递给一个把分组 byte 当作 int 进行操作的方法,该方法可以安全有效地执行。

Span<T> 是如何实现的?

开发人员通常不需要了解他们使用的库是如何实现的。 但是,对于 Span <T>,了解其实现细节是很值得的,从中我们可以推断出它的性能和使用限制。

首先,Span <T>是一个包含 ref 和 length 的值类型,大致定义如下:

public readonly ref struct Span<T>
{
  private readonly ref T _pointer;
  private readonly int _length;
  ...
}

ref T 字段的概念起初可能很奇怪 —— 实际上,我们不能在C#中甚至在MSIL中声明 ref T 字段。 但是 Span <T> 实际上是使用 CLR 特殊的内部类型编写的,它是 JIT 的一个内部函数,JIT 会等效地将该字段生成一个 ref T 字段。

参考一个更常见的 ref 用法案例:

public static void AddOne(ref int value) => value += 1;
...

var values = new int[] { 42, 84, 126 };
AddOne(ref values[2]);
Assert.Equal(127, values[2]);

这段代码通过引用传递数组中的一个槽(slot),这样(除了优化)你在堆栈上有一个 ref T . Span <T> 中的 ref T 是相同的理念,只是封装在结构中。 直接或间接包含此类 ref 的类型,被称为  ref-like 类型,C#7.2 编译器允许通过在签名中使用 ref 结构来声明此类 ref-like 的类型。

综上所述,应该清楚两件事:

  1. Span <T> 的定义使得 它的操作 可以与数组一样高效:索引到 span 中不需要计算来确定指针的起点及其起始偏移量,因为ref 字段本身已经封装了两者。 (相比之下,ArraySegment <T>有一个单独的偏移字段,使索引和传递更加低效。)
  2. Span <T> 作为 ref-like 类型,由于其ref T字段,也带来了一些限制。

第二条导致了一些有趣的结果 —— .NET 里有另一个相关类型:Memory <T> 。

Memory<T> 又是什么鬼?

Span<T> 中含有一个 ref 字段,ref 字段不仅可以指向对象的开头,也可以指向对象中间:

var arr = new byte[100];
Span<byte> interiorRef1 = arr.AsSpan(start: 20);
Span<byte> interiorRef2 = new Span<byte>(arr, 20, arr.Length – 20);
Span<byte> interiorRef3 =
   MemoryMarshal.CreateSpan<byte>(arr, ref arr[20], arr.Length – 20);

这些引用称为内部指针,跟踪它们对于 .NET 运行时的 GC 来说是一个相对代价较高的操作。 因此,运行时将这些引用限制在堆栈(stack)上,因为它提供了可能存在的内部指针数量的隐式下限。

Span<T> 比机器的一个字节要大,这导致一个Span 的读写不是原子操作。如果多个线程同时读写堆上的 span 的字段,这会存在着线程安全问题。

因此,Span<T> 的实例只能放在堆栈上,不能放在堆上。因此,不能对 Span<T> 进行装箱操作(例如,不能对 Span<T>使用已有的反射调用 API,因为他们用到了装箱)。于是,在类中,不能含有 Span<T> 字段,甚至在 非 ref-like 结构体中也不能有 Span<T> 字段。而且,也不能在可能隐式地成为类的字段的地方使用它,例如把它放在 lambda 中或者在异步方法或迭代器中的局部变量(因为这些局部变量可能会最终成为编译器生成的状态机的字段)。也不能把 Span<T> 当做泛型参数来使用,因为该类型参数的实例最终有可能被装箱或以其他方式被存储到堆中(目前还没有 where T : ref struct 限制)。

这些限制在很多场景下并不重要,特别是对于计算密集型和同步方法。但是异步方法就不一样了。无论是同步处理操作还是异步处理操作,本文开头提到的关于数组、数组切片、本机内存等大多数问题都存在。然鹅,如果 Span<T> 无法被存储在堆中,因此不能跨异步操作进行持久化,那么怎么解决呢?答案就是 Memory<T>。

Memory<T> 看起来跟 ArraySegment<T> 很像:

public readonly struct Memory<T>
{
  private readonly object _object;
  private readonly int _index;
  private readonly int _length;
  ...
}

你可以从数组创建一个 Memory<T> 然后像 span 一样切分它。但是它是个 非 ref-like 结构体,因此可以存储在堆上。于是,你若想做同步处理,你可以用它来创建一个 Span<T>,例如:

static async Task<int> ChecksumReadAsync(Memory<byte> buffer, Stream stream)
{
  int bytesRead = await stream.ReadAsync(buffer);
  return Checksum(buffer.Span.Slice(0, bytesRead));
  // Or buffer.Slice(0, bytesRead).Span
}
static int Checksum(Span<byte> buffer) { ... }

同样的, Memory<T> 也有一个只读版本:ReadOnlyMemory<T> ,它的 Span 属性也返回 ReadOnlySpan<T> 。下表列出了这些类型互相转换的内建机制:

                                 表1  Span 相关类型之间的 无需内存分配/无需拷贝 的转换

FromToMechanism
ArraySegment<T>Memory<T>隐式转换, AsMemory() 方法
ArraySegment<T>ReadOnlyMemory<T>隐式转换, AsMemory() 方法
ArraySegment<T>ReadOnlySpan<T>隐式转换, AsSpan() 方法
ArraySegment<T>Span<T>隐式转换, AsSpan() 方法
ArraySegment<T>T[]Array 属性
Memory<T>ArraySegment<T>MemoryMarshal.TryGetArray() 方法
Memory<T>ReadOnlyMemory<T>隐式转换, AsMemory() 方法
Memory<T>Span<T>Span 属性
ReadOnlyMemory<T>ArraySegment<T>MemoryMarshal.TryGetArray() 方法
ReadOnlyMemory<T>ReadOnlySpan<T>Span 属性
ReadOnlySpan<T>ref readonly TIndexer get accessor, 一些 marshaling 方法
Span<T>ReadOnlySpan<T>隐式转换, AsSpan() 方法
Span<T>ref TIndexer get accessor, 一些 marshaling 方法
StringReadOnlyMemory<char>AsMemory() 方法
StringReadOnlySpan<char>隐式转换, AsSpan() 方法
T[]ArraySegment<T>Ctor, 隐式转换
T[]Memory<T>Ctor, 隐式转换, AsMemory() 方法
T[]ReadOnlyMemory<T>Ctor, 隐式转换, AsMemory() 方法
T[]ReadOnlySpan<T>Ctor, 隐式转换, AsSpan () 方法
T[]Span<T>Ctor, 隐式转换, AsSpan() 方法
void*ReadOnlySpan<T>Ctor
void*Span<T>Ctor

    你也许注意到了, Memory<T> 的 _object 字段没有用 T [] 限定类型,它仅仅是个 object。这表明了 Memory<T> 可以包装除了数组以外的东西,例如 System.Buffers.OwnedMemory<T>。 OwnedMemory <T> 是一个抽象类,可用于包装需要严格管理生命周期的数据,例如从池中检索的内存。 这个主题超出了本文范围,但这就是使用 Memory <T> 来,例如,将指针包装到本机内存中的机制。ReadOnlyMemory <char> 也可以与字符串一起使用,就像ReadOnlySpan <char> 一样。

Span<T> 和 Memory<T> 是如何与 .NET 库集成的?

在之前的 Memory <T> 代码片段中,您会注意到对 Stream.ReadAsync 的调用传递了一个 Memory<byte> 参数。但是如今的 .NET 中的Stream.ReadAsync 被定义为接受 byte [] 参数。 这是如何运作的?

为了支持Span <T>和它的朋友们,在.NET 中添加了数百个新成员和类型。 其中许多是现有的 基于数组和字符串的方法的重载,而有些则是专注于特定处理区域的全新类型。 例如,像 Int32 这样的所有基本类型 的 Parse() 方法,除了原有的以 string 作为参数的重载以外,现在都具有接受 ReadOnlySpan <char> 作为参数的重载。 想象一下这样一种情况,你期望解析一个包含两个以逗号分隔的数字的字符串(例如“123,456”)。 今天你可以写这样的代码:

string input = ...;
int commaPos = input.IndexOf(',');
int first = int.Parse(input.Substring(0, commaPos));
int second = int.Parse(input.Substring(commaPos + 1));

但是,这会产生两个字符串分配。 如果您正在编写对性能敏感的代码,则可能是两个字符串分配太多。 相反,你现在可以这样写:

string input = ...;
ReadOnlySpan<char> inputSpan = input;
int commaPos = input.IndexOf(',');
int first = int.Parse(inputSpan.Slice(0, commaPos));
int second = int.Parse(inputSpan.Slice(commaPos + 1));

通过使用新的基于 Span 的 Parse 重载,您已经完成了整个操作的免分配。 类似的解析和格式化方法存在于 Int32 这样的原语,以及像DateTime,TimeSpan 和 Guid 这样的核心类型,甚至更高级的类型,如 BigInteger 和 IPAddress。

实际上,在整个框架中添加了许多这样的方法。 从 System.Random 到 System.Text.StringBuilder 再到 System.Net.Sockets,添加了重载以使 {ReadOnly} Span <T>和 {ReadOnly} Memory <T> 变得简单而高效。 其中一些甚至带来额外的好处。 例如,Stream现在有这个方法:

public virtual ValueTask<int> ReadAsync( Memory<byte> destination,
   CancellationToken cancellationToken = default) 
{ ... }

注意到,与接受 byte [] 并返回 Task <int> 的现有 ReadAsync 方法不同,此重载不仅接受 Memory <byte> ,而且还返回 ValueTask <int> 而不是 Task<int>。 ValueTask <T> 是一个结构,它可以避免以下两种情况的内存分配:(1)异步方法频繁进行同步返回;(2)难以缓存所有公共返回值。 例如,运行时可以将完成的Task <bool> 缓存为 true 或者 false,但是它不能为 Task <int> 的所有可能结果值缓存40亿个 int 对象。

由于在 Stream 的实现中,我们经常以同步的方式调用 ReadAsync 来缓冲数据,所以这个新的 ReadAsync 重载返回一个ValueTask <int>。 这意味着同步完成的异步流读取操作可以不必再分配内存。 ValueTask <T>也用于其他新的重载,例如 Socket.ReceiveAsync,Socket.SendAsync,WebSocket.ReceiveAsync 和 TextReader.ReadAsync 的重载。

此外,还有一些地方,Span <T> 允许框架包含过去引起内存安全问题的方法。 考虑一种情况:你希望创建一个由随机生成的 char 组成的字符串,例如某种 ID。 你可能需要分配一个 char 数组,如下所示:

int length = ...;
Random rand = ...;
var chars = new char[length];
for (int i = 0; i < chars.Length; i++)
{
  chars[i] = (char)(rand.Next(0, 10) + '0');
}
string id = new string(chars);

你可以使用 堆栈分配(stack-allocation),甚至利用 Span <char>,以避免使用不安全的代码。 这种方法还利用了 一个新的 参数为 ReadOnlySpan <char> 的字符串构造函数,如下所示:

int length = ...;
Random rand = ...;
Span<char> chars = stackalloc char[length];
for (int i = 0; i < chars.Length; i++)
{
  chars[i] = (char)(rand.Next(0, 10) + '0');
}
string id = new string(chars);

这样做更好,因为避免了堆分配,但仍然需要将栈中生成的数据复制到字符串中。 这种方法也只适用于所需的空间量足够小的堆栈。 如果长度很短,比如32个字节,那很好,但是如果它是几千个字节,很容易导致堆栈溢出的情况。 如果你可以直接写入字符串的内存会怎样? Span <T>允许这样做。 除了string的新构造函数之外,string现在还有一个Create方法:

public static string Create<TState>(int length, TState state, SpanAction<char, TState> action);
...
public delegate void SpanAction<T, in TArg>(Span<T> span, TArg arg);

该方法用来创建一个字符串,传入一个可写的 Span,以便在构造字符串时填充字符串的内容。 请注意,Span <T> 的仅存在堆栈上的特性在这种情况下是有益的,保证了在字符串构造函数完成之前,span(指向字符串的内部存储)将被销毁,从而无法再使用 span 来改变构造的字符串:

int length = ...;
Random rand = ...;
string id = string.Create(length, rand, (Span<char> chars, Random r) =>
{
  for (int i = 0; chars.Length; i++)
  {
    chars[i] = (char)(r.Next(0, 10) + '0');
  }
});

(译者注:这里,Span<char> 相当于是一个指针,指向了堆中的字符串)

现在,我们不仅避免了内存分配,而且实现了直接将内容写到字符串在堆上的内存。这意味着我们避免了复制,因此可以不受堆栈大小的限制。

除了扩展了框架中一些核心类型的成员变量之外,微软还在持续开发新的 .NET 类型,以便使用 Span 高效处理某些特定的场景。 例如,对于编写高性能微服务和大量文本处理的 Web 站点的开发人员来说,如果在使用UTF-8时不必进行编码和解码,则可以获得显着的性能提升。 为了实现这一点,微软正在开发新的类型,如 System.Buffers.Text.Base64,System.Buffers.Text.Utf8Parser 和System.Buffers.Text.Utf8Formatter。 它们在 字节 Span 上运行,这不仅避免了Unicode 编码和解码,而且使它们能够使用在各种网络堆栈中常见的本机缓冲区:

ReadOnlySpan<byte> utf8Text = ...;
if (!Utf8Parser.TryParse(utf8Text, out Guid value,
  out int bytesConsumed, standardFormat = 'P'))
{
   throw new InvalidDataException();
}

所有这些功能不仅仅是为了给公众使用,相反,Framework 本身能够利用这些 基于Span <T> 和 Memory <T> 的新方法来提升性能。 跨.NET Core 的调用站点已切换到使用新的 ReadAsync 重载以避免不必要的分配。 以往需要分配子字符串才能完成的解析功能,现在都利用了无分配的方式进行解析。 甚至像 Rfc2898DeriveBytes 这样的小众类型都进行了改进,利用System.Security.Cryptography.HashAlgorithm 上新的基于 Span <byte> 的 TryComputeHash() 方法节省了大量的内存空间(每次迭代算法的字节数组, 这可能会重复数千次),提高了吞吐量。

这并不止于核心 .NET 库的层次,它也延伸到堆栈中。 ASP.NET Core 现在严重依赖于 Span,例如,在它们之上编写了 Kestrel 服务器的HTTP 解析器。 将来,Span 可能会暴露在较低级别的 ASP.NET Core 的公共 API 之外,例如在其中间件管道中。

.NET 运行时有何变化?

.NET 运行时确保安全性的方法之一是确保数组索引不超出数组的长度,这种做法称为边界检查。 例如这个方法:

[MethodImpl(MethodImplOptions.NoInlining)]
static int Return4th(int[] data) => data[3];

在 X64 平台上,生成的程序集如下:

sub      rsp, 40
       cmp      dword ptr [rcx+8], 3
       jbe      SHORT G_M22714_IG04
       mov      eax, dword ptr [rcx+28]
       add      rsp, 40
       ret
G_M22714_IG04:
       call     CORINFO_HELP_RNGCHKFAIL
       int3

其中的 cmp 指令将数据数组的长度与索引3进行比较,随后的 jbe 指令跳转到范围检查失败例程,如果3超出范围(对于要抛出的异常)。 JIT 需要生成代码以确保此类访问不会超出数组的范围,但这并不意味着每个单独的数组访问都需要绑定检查。 考虑这个Sum方法:

static int Sum(int[] data)
{
  int sum = 0;
  for (int i = 0; i < data.Length; i++) sum += data[i];
  return sum;
}

这里 JIT 需要生成代码,以确保对数据 [i] 的访问不会超出数组的范围,但是因为JIT可以从循环的结构告诉我将始终在范围内(循环迭代) 通过从开始到结束的每个元素,JIT 可以优化数组上的边界检查。 因此,为循环生成的汇编代码如下所示:

G_M33811_IG03:
       movsxd   r9, edx
       add      eax, dword ptr [rcx+4*r9+16]
       inc      edx
       cmp      r8d, edx
       jg       SHORT G_M33811_IG03

cmp 指令依然存在,但只是将 i 的值(存储在edx寄存器中)与数组的长度(存储在r8d寄存器中)进行比较; 没有额外的边界检查。

运行时Runtime 将类似的优化应用于 span(Span <T>和ReadOnlySpan <T>)。 将前面的示例与以下代码进行比较,其中唯一的更改是参数类型:

static int Sum(Span<int> data)
{
  int sum = 0;
  for (int i = 0; i < data.Length; i++) sum += data[i];
  return sum;
}

生成的程序集几乎是差不多的:

G_M33812_IG03:
       movsxd   r9, r8d
       add      ecx, dword ptr [rax+4*r9]
       inc      r8d
       cmp      r8d, edx
       jl       SHORT G_M33812_IG03

汇编代码非常相似,部分原因是消除了边界检查。 但同样重要的是 JIT 将 span 索引器识别为内部的,这意味着JIT为索引器生成特殊代码,而不是将其实际的IL代码转换为汇编。

所有这些都是为了说明运行时就像 Array 一样 可以为 Span 做优化,从而使 Span 成为访问数据的有效机制。 更多详细信息可在博客 bit.ly/2zywvyI 中找到。

C# 语言及其编译器有啥变化?

我已经提到了 C#语言和编译器新增的功能,这些功能使得 Span <T> 成为 .NET 中的上等公民。 C#7.2 的几个特性与 Span 相关(事实上,使用 Span <T> 需要C#7.2 编译器)。 我们来看看三个这样的功能。

(1)引用结构(Ref Struct)

如前所述,Span <T> 是一种 ref-like 类型,它在 C# 7.2 中作为 ref struct 公布。 通过在 struct 之前放置 ref关键字,您可以告诉 C#编译器允许您使用其他 ref struct 类型(如Span <T>)作为字段,并且这种约束也会传递到将要分配的类型中。 例如,如果你想为Span <T> 编写一个 struct Enumerator,那么 Enumerator 需要存储Span <T>,因此,它本身需要是一个ref 结构,如下所示:

public ref struct Enumerator
{
  private readonly Span<char> _span;
  private int _index;
  ...
}

(2)Span 的 Stackalloc 初始化(Stackalloc initialization of spans)。 

在以前的C#版本中,stackalloc 的结果只能存储在指针局部变量中。 从C#7.2开始,stackalloc 现在可以用作表达式的一部分并且可以指向一个 Span,并且可以在不使用 unsafe 关键字的情况下完成。 因此,我们不必再这样写:

Span<byte> bytes;
unsafe
{
  byte* tmp = stackalloc byte[length];
  bytes = new Span<byte>(tmp, length);
}

我们可以这么写:

Span<byte> bytes = stackalloc byte[length];

在需要一些临时空间来执行操作,但希望避免分配相对较小的堆内存的情况下,这也非常有用。 在以前,有两种实现方式:

  • 编写两个完全不同的代码路径,一个分配栈的内存并进行相关操作,另一个基于堆内存进行操作。
  • 固定与分配的托管内存,然后委托给同样是基于栈的、使用 unsafe 指针代码的内存的实现。

现在,使用安全的代码和尽量少的折腾,同样的事情可以在没有代码冗余的情况下完成:

Span<byte> bytes = length <= 128 ? stackalloc byte[length] : new byte[length];
... // Code that operates on the Span<byte>

(3)Span 使用验证(Span usage validation)

因为 Span 可以指向与给定栈帧相关联的数据,所以可能出现 传递的 span 指向的内存不再可用 这一危险情况。 例如,想象一下某个方法做如下操作:

static Span<char> FormatGuid(Guid guid)
{
  Span<char> chars = stackalloc char[100];
  bool formatted = guid.TryFormat(chars, out int charsWritten, "d");
  Debug.Assert(formatted);
  return chars.Slice(0, charsWritten); // Uh oh
}

这里,从堆栈中分配空间,然后尝试返回对该空间的引用,但是当返回时,该空间将不再有效(栈帧执行完被释放掉了,译者注)。 值得庆幸的是,C#编译器使用 ref 结构检测到这种无效用法,并且编译失败并出现错误:

Error CS8352:在此上下文中不能使用本地 “chars”,因为它可能会在其声明范围之外暴露引用的变量

接下来呢?

这里讨论的类型,方法,运行时优化和其他元素有望包含在.NET Core 2.1中。 之后,我希望他们能够进入.NET Framework。 像Span <T> 这样的核心类型,以及像 Utf8Parser 这样的新类型,也有望在与.NET Standard 1.1 兼容的 System.Memory.dll 包中提供。 这将使现有.NET Framework 和.NET Core 版本的功能可用,尽管在内置到平台时没有实现一些优化。 今天可以试用这个包的预览 - 只需添加对NuGet 的 System.Memory.dll 包的引用。

当然,请记住,当前预览版本与稳定版本中实际发布的内容之间可能会发生重大变化。 这些变化在很大程度上是由于您在尝试使用功能集时来自像您这样的开发人员的反馈。 所以请试一试,并密切关注 github.com/dotnet/coreclr 和 github.com/dotnet/corefx 存储库以了解正在进行的工作。 您也可以在 aka.ms/ref72 找到文档。

最终,这个功能集的成功依赖于开发人员尝试它,提供反馈,并利用这些类型构建自己的库,所有这些都旨在提供对现代.NET程序中内存的高效和安全访问。 我们期待收到您的经验,甚至更好地与您在GitHub上合作,进一步改进.NET。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值