原文: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
System.Span
Span的特点如下:
例如,我们可以通过一个数组创建一个 Span
var arr = new byte[10];
Span bytes = arr; // Implicit cast from T[] to Span
由此,利用span 的 一个 Slice() 重载,我们可以轻易地创建一个 指向/代表 数组的一个子集的 span。
Span 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 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 bytes;
unsafe
{
bytes = new Span((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
public struct Span
{
ref T _reference;
int _length;
public ref T this[int index] { get {...} }
...
}
public struct ReadOnlySpan
{
ref T _reference;
int _length;
public T this[int index] { get {...} }
...
}
ref return 索引器带来的影响可以通过与List
struct MutableStruct
{
public int Value;
}
...
Span spanOfStructs = new MutableStruct[1];
spanOfStructs[0].Value = 42;
Assert.Equal(42, spanOfStructs[0].Value);
var listOfStructs = new List { new MutableStruct() };
listOfStructs[0].Value = 42; // Error CS1612: the return value is not a variable
Span
string str = "hello, world";
string worldString = str.Substring(startIndex: 7, length: 5); // Allocates
ReadOnlySpan 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
开发人员通常不需要了解他们使用的库是如何实现的。 但是,对于 Span
首先,Span
public readonly ref struct Span
{
private readonly ref T _pointer;
private readonly int _length;
...
}
ref T 字段的概念起初可能很奇怪 —— 实际上,我们不能在C#中甚至在MSIL中声明 ref T 字段。 但是 Span
参考一个更常见的 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
综上所述,应该清楚两件事:
第二条导致了一些有趣的结果 —— .NET 里有另一个相关类型:Memory
Span
var arr = new byte[100];
Span interiorRef1 = arr.AsSpan(start: 20);
Span interiorRef2 = new Span(arr, 20, arr.Length – 20);
Span interiorRef3 =
MemoryMarshal.CreateSpan(arr, ref arr[20], arr.Length – 20);
这些引用称为内部指针,跟踪它们对于 .NET 运行时的 GC 来说是一个相对代价较高的操作。 因此,运行时将这些引用限制在堆栈(stack)上,因为它提供了可能存在的内部指针数量的隐式下限。
Span
因此,Span
这些限制在很多场景下并不重要,特别是对于计算密集型和同步方法。但是异步方法就不一样了。无论是同步处理操作还是异步处理操作,本文开头提到的关于数组、数组切片、本机内存等大多数问题都存在。然鹅,如果 Span
Memory
public readonly struct Memory
{
private readonly object _object;
private readonly int _index;
private readonly int _length;
...
}
你可以从数组创建一个 Memory
static async Task ChecksumReadAsync(Memory 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 buffer) { ... }
同样的, Memory
表1 Span 相关类型之间的 无需内存分配/无需拷贝 的转换
From | To | Mechanism |
ArraySegment |
Memory |
隐式转换, AsMemory() 方法 |
ArraySegment |
ReadOnlyMemory |
隐式转换, AsMemory() 方法 |
ArraySegment |
ReadOnlySpan |
隐式转换, AsSpan() 方法 |
ArraySegment |
Span |
隐式转换, AsSpan() 方法 |
ArraySegment |
T[] | Array 属性 |
Memory |
ArraySegment |
MemoryMarshal.TryGetArray() 方法 |
Memory |
ReadOnlyMemory |
隐式转换, AsMemory() 方法 |
Memory |
Span |
Span 属性 |
ReadOnlyMemory |
ArraySegment |
MemoryMarshal.TryGetArray() 方法 |
ReadOnlyMemory |
ReadOnlySpan |
Span 属性 |
ReadOnlySpan |
ref readonly T | Indexer get accessor, 一些 marshaling 方法 |
Span |
ReadOnlySpan |
隐式转换, AsSpan() 方法 |
Span |
ref T | Indexer get accessor, 一些 marshaling 方法 |
String | ReadOnlyMemory |
AsMemory() 方法 |
String | ReadOnlySpan |
隐式转换, AsSpan() 方法 |
T[] | ArraySegment |
Ctor, 隐式转换 |
T[] | Memory |
Ctor, 隐式转换, AsMemory() 方法 |
T[] | ReadOnlyMemory |
Ctor, 隐式转换, AsMemory() 方法 |
T[] | ReadOnlySpan |
Ctor, 隐式转换, AsSpan () 方法 |
T[] | Span |
Ctor, 隐式转换, AsSpan() 方法 |
void* | ReadOnlySpan |
Ctor |
void* | Span |
Ctor |
你也许注意到了, Memory
在之前的 Memory
为了支持Span
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 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
public virtual ValueTask ReadAsync( Memory destination,
CancellationToken cancellationToken = default)
{ ... }
注意到,与接受 byte [] 并返回 Task
由于在 Stream 的实现中,我们经常以同步的方式调用 ReadAsync 来缓冲数据,所以这个新的 ReadAsync 重载返回一个ValueTask
此外,还有一些地方,Span
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
int length = ...;
Random rand = ...;
Span 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
public static string Create(int length, TState state, SpanAction action);
...
public delegate void SpanAction(Span span, TArg arg);
该方法用来创建一个字符串,传入一个可写的 Span,以便在构造字符串时填充字符串的内容。 请注意,Span
int length = ...;
Random rand = ...;
string id = string.Create(length, rand, (Span chars, Random r) =>
{
for (int i = 0; chars.Length; i++)
{
chars[i] = (char)(r.Next(0, 10) + '0');
}
});
(译者注:这里,Span
现在,我们不仅避免了内存分配,而且实现了直接将内容写到字符串在堆上的内存。这意味着我们避免了复制,因此可以不受堆栈大小的限制。
除了扩展了框架中一些核心类型的成员变量之外,微软还在持续开发新的 .NET 类型,以便使用 Span 高效处理某些特定的场景。 例如,对于编写高性能微服务和大量文本处理的 Web 站点的开发人员来说,如果在使用UTF-8时不必进行编码和解码,则可以获得显着的性能提升。 为了实现这一点,微软正在开发新的类型,如 System.Buffers.Text.Base64,System.Buffers.Text.Utf8Parser 和System.Buffers.Text.Utf8Formatter。 它们在 字节 Span 上运行,这不仅避免了Unicode 编码和解码,而且使它们能够使用在各种网络堆栈中常见的本机缓冲区:
ReadOnlySpan utf8Text = ...;
if (!Utf8Parser.TryParse(utf8Text, out Guid value,
out int bytesConsumed, standardFormat = 'P'))
{
throw new InvalidDataException();
}
所有这些功能不仅仅是为了给公众使用,相反,Framework 本身能够利用这些 基于Span
这并不止于核心 .NET 库的层次,它也延伸到堆栈中。 ASP.NET Core 现在严重依赖于 Span,例如,在它们之上编写了 Kestrel 服务器的HTTP 解析器。 将来,Span 可能会暴露在较低级别的 ASP.NET Core 的公共 API 之外,例如在其中间件管道中。
.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
static int Sum(Span 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#语言和编译器新增的功能,这些功能使得 Span
(1)引用结构(Ref Struct)。
如前所述,Span
public ref struct Enumerator
{
private readonly Span _span;
private int _index;
...
}
(2)Span 的 Stackalloc 初始化(Stackalloc initialization of spans)。
在以前的C#版本中,stackalloc 的结果只能存储在指针局部变量中。 从C#7.2开始,stackalloc 现在可以用作表达式的一部分并且可以指向一个 Span,并且可以在不使用 unsafe 关键字的情况下完成。 因此,我们不必再这样写:
Span bytes;
unsafe
{
byte* tmp = stackalloc byte[length];
bytes = new Span(tmp, length);
}
我们可以这么写:
Span bytes = stackalloc byte[length];
在需要一些临时空间来执行操作,但希望避免分配相对较小的堆内存的情况下,这也非常有用。 在以前,有两种实现方式:
现在,使用安全的代码和尽量少的折腾,同样的事情可以在没有代码冗余的情况下完成:
Span bytes = length <= 128 ? stackalloc byte[length] : new byte[length];
... // Code that operates on the Span
(3)Span 使用验证(Span usage validation)。
因为 Span 可以指向与给定栈帧相关联的数据,所以可能出现 传递的 span 指向的内存不再可用 这一危险情况。 例如,想象一下某个方法做如下操作:
static Span FormatGuid(Guid guid)
{
Span 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
当然,请记住,当前预览版本与稳定版本中实际发布的内容之间可能会发生重大变化。 这些变化在很大程度上是由于您在尝试使用功能集时来自像您这样的开发人员的反馈。 所以请试一试,并密切关注 github.com/dotnet/coreclr 和 github.com/dotnet/corefx 存储库以了解正在进行的工作。 您也可以在 aka.ms/ref72 找到文档。
最终,这个功能集的成功依赖于开发人员尝试它,提供反馈,并利用这些类型构建自己的库,所有这些都旨在提供对现代.NET程序中内存的高效和安全访问。 我们期待收到您的经验,甚至更好地与您在GitHub上合作,进一步改进.NET。