关于 Span 的一切:探索新的 .NET 明星: 4. Span<T> 和 Memory<T> 是如何与 .NET 库集成的?
4. Span<T> 和 Memory<T> 是如何与 .NET 库集成的?
- 1. Span<T> 是什么?
- 2. Span<T> 是如何实现的?
- 3. 什么是 Memory<T>,以及为什么你需要它?
- 4. Span
和 Memory 是如何与 .NET 库集成的? - 5. NET 运行时
- 6. C# 语言和编译器受到什么影响?
在前面的 Memory<T> 代码片段中,你应该注意到,在调用 Stream.ReadAsync() 的方法中传递了 Memory<byte> 参数。但是现在的 .NET 中的 Stream.ReadAsnc() 实际上接受类型为 byte[]。这是怎么工作的呢?
为了支持 Span<T> 和相关类型,数百个新的成员和类型被添加到 .NET 的各个部分。多数是现在的基于数组和基于字符串的重载方法,其它全新的类型则专注于特性的处理领域。例如,所有的基础类型,例如 Int32 现在在支持字符串的基础上,拥有支持 ReadOnlySpan123,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.StringBuffer 到 System.Net.Sockets,这些添加的重载使得处理 {ReadOnly}Span<T> 和 {ReadOnly}Memory<T> 更为简单和高效。有些还带来了额外的优点。例如,Stream 现在由这个方法:
public virtual ValueTask<int> ReadAsync(
Memory<byte> destination,
CancellationToken cancellationToken = default) { ... }
你将会注意到与现有的 ReadAsync() 方法接受一个 byte[] 并返回一个 Task<int> 不同,这个重载方法不仅接受一个 Memory\<byte>
来代替 byte[]
,还返回一个 ValueTask\<int>
而不是 Task\<int>
。ValueTask\<int>
是用来帮助避免内存分配的结构,异步方法被频繁期待同步返回的场景,与我们缓存所有通常返回值不同。例如,运行时可以缓存完成的 Task<bool> 的值 true,和另外一个 false,但是它不能对于 Task<int> 所对应的所有整数返回值。
因为对于流的实现很常见的场景是通过某种方式缓存,使得 ReadAsync() 方法调用同步使用,这个新的 ReadAsync() 重载方法返回 ValueTask<int> 值。这意味着同步完成的异步流读取操作可以完全避免内存分配。ValueTask<int> 也用于其它的重载,比如 Socket.ReceiveAsync(),Socket.SendAsync(),WebSocket.ReceiveAsync() 和 TextReader.ReadAsync()。
此外,在某些地方,Span<T> 允许框架包含过去引起内存安全问题的方法。请考虑这样一种场景:你希望创建一个包含随机生成值的字符串,例如某种 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);
你可以使用堆栈分配内存,进而获得 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> 支持你做到这一点。除了字符串的新构造函数,字符串还有一个新的 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
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');
}
});
现在,你不仅可以避免内存分配,你还直接写入在堆上分配的字符串内存,这意味着你也避免了内存复制,并且也没有被堆栈的尺寸所限制。
除了核心框架类型中新增加的成员,众多新的 .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();
所有这些功能不仅可以公共使用,框架本身也从基于 Span<T> 和 Memory<T> 的方法中获得更好的性能。跨 .NET Core 的呼叫站点已切换到使用新的 ReadAsync 重载,以避免不必要的分配。原来完成的通过分配子字符串的解析,现在通过无分配的解析获益。甚至更小的类型,例如 Rfc2898DeriveBytes 也使用了这种方式,在 System.Security.Cryptography.HashAlgorithm 中的 TryComputeHash() 方法使用新的基于 Span
这些不止于核心 .NET 库;它延展到所有的技术栈。ASP.NET Core 现在重度依赖于 Span,例如,Kestrel 服务器的 HTTP 解析器基于此开发。将来,Span 很可能会从较低级别的 ASP.NET Core(例如其中间件管道)中的公共 API 中公开。