C# Span<T> 是 .NET Core 2.1 引入的一项强大功能,旨在提高性能并减少内存分配,尤其是在处理数组和内存缓冲区时。它提供了一种类型安全、内存安全且无需复制即可访问连续内存区域的方法。
什么是 Span<T>?
Span<T> 是一个结构体(ref struct),这意味着它只能分配在栈上,并且不能被装箱(boxed)到堆上。它也不能作为字段存储在类中,不能用于异步方法(async/await)或迭代器(yield return)。这些限制确保了其内存安全性和高性能。
Span<T> 封装了对任意连续内存块的引用,而不管该内存是在堆、栈还是非托管内存中。它由两个主要部分组成:
1. 起始指针(Pointer):指向内存区域的起始地址。
2. 长度(Length):内存区域中元素的数量。
为什么需要 Span<T>?
在 Span<T> 出现之前,处理内存通常涉及以下几种情况:
- 数组和子数组:当需要处理数组的一部分时,通常会创建数组的副本,这会导致额外的内存分配和复制开销。例如,
byte[] subarray = new byte[length]; Array.Copy(originalArray, offset, subarray, 0, length); - 字符串操作:字符串的子串操作也会创建新的字符串对象,导致内存分配。
- 非托管内存:与非托管内存交互时,通常需要使用
unsafe代码和指针,这增加了复杂性和潜在的内存安全问题。 Stackalloc:虽然可以在栈上分配内存,但stackalloc返回的是指针,使用起来不方便且不安全。
Span<T> 的引入解决了这些问题,它允许你:
- 避免内存复制:
Span<T>提供了对现有内存的“视图”,而不是创建副本。这显著减少了内存分配和垃圾回收的压力,从而提高了应用程序的性能。 - 统一的内存访问:无论内存来自数组、字符串、栈还是非托管内存,
Span<T>都能提供统一的、类型安全的方式来访问它。 - 内存安全:即使是在处理指针的情况下,
Span<T>也提供了边界检查,防止越界访问,从而减少了常见的内存错误。 - 与
stackalloc结合:Span<T>可以很好地与stackalloc结合使用,从而在栈上分配内存并以类型安全的方式访问它,避免了unsafe关键字。
如何创建 Span<T>?
Span<T> 可以从多种数据源创建:
-
从数组创建:
csharp
byte[] byteArray = new byte[100];
Span<byte> span = new Span<byte>(byteArray);
// 或者从数组的一部分创建
Span<byte> subSpan = new Span<byte>(byteArray, 10, 20); // 从索引10开始,长度为20 -
从
stackalloc内存创建:
csharp
Span<byte> stackSpan = stackalloc byte[128]; // 只能在方法内部使用 -
从
string创建 (ReadOnlySpan<char>):
由于字符串是不可变的,所以只能创建ReadOnlySpan<char>。
csharp
string myString = "Hello, World!";
ReadOnlySpan<char> charSpan = myString.AsSpan();
// 从字符串的一部分创建
ReadOnlySpan<char> subCharSpan = myString.AsSpan(7, 5); // "World" -
从指针和长度创建(需要
unsafe上下文):
csharp
unsafe
{
byte[] buffer = new byte[100];
fixed (byte* ptr = buffer)
{
Span<byte> span = new Span<byte>(ptr, buffer.Length);
}
}
Span<T> 的使用
Span<T> 的使用方式与数组非常相似,支持索引访问和 foreach 循环。
“`csharp
byte[] data = new byte[] { 1, 2, 3, 4, 5 };
Span
// 索引访问
Console.WriteLine(span[0]); // 输出 1
// 修改 Span 的内容会修改原始内存
span[0] = 10;
Console.WriteLine(data[0]); // 输出 10
// 切片 (Slice) – 创建一个新的 Span,指向原始内存的子区域
Span
Console.WriteLine(subSpan[0]); // 输出 2
// 遍历
foreach (byte b in subSpan)
{
Console.WriteLine(b);
}
“`
Span<T> 的主要特性和优点
-
ref struct限制:- 不能装箱。
- 不能作为类字段。
- 不能在异步方法或迭代器中使用。
- 不能实现接口(但可以转换为实现接口的类型,例如
IFormattable)。 - 这些限制是为了保证
Span<T>始终引用栈上的有效内存,避免垃圾回收器移动它。
-
ReadOnlySpan<T>:
用于表示不可变的连续内存区域,例如字符串。它与Span<T>具有相同的性能优势,但禁止写入操作。 -
性能优势:
- 减少内存分配:避免了子数组或子字符串的创建,从而减少了堆内存的使用。
- 减少垃圾回收压力:因为减少了堆分配,垃圾回收器需要做的工作也更少,提高了应用程序的响应速度。
- CPU 缓存效率:由于数据是连续存储的,并且避免了不必要的复制,CPU 缓存的利用率更高。
-
与现有 API 集成:
.NET 框架中的许多 API 已经更新,以接受或返回Span<T>或ReadOnlySpan<T>,从而可以直接利用其性能优势。例如,Encoding.UTF8.GetBytes()有接受Span<byte>的重载。 -
Memory<T>和ReadOnlyMemory<T>:
Memory<T>是Span<T>的堆版本。它是一个类,可以存储在堆上,也可以作为类字段。Memory<T>可以安全地在async/await和迭代器中使用。你可以通过Memory<T>.Span属性获取其对应的Span<T>。通常,Memory<T>用于跨多个方法或异步操作共享内存缓冲区,而Span<T>则用于在单个方法调用内部进行高性能的内存访问。csharp
byte[] array = new byte[100];
Memory<byte> memory = array;
// ... 可以在异步方法中传递 memory ...
Span<byte> spanFromMemory = memory.Span; // 获取 Span 进行操作
适用场景
Span<T> 在以下场景中尤其有用:
- 高性能 I/O 操作:处理网络流、文件读取等,避免数据复制。
- 解析器和序列化器:处理大的文本或二进制数据块,如 JSON、CSV、Protobuf 等。
- 图像处理:操作图像像素数据,避免创建子图像。
- 任何需要处理大型连续内存块且对性能敏感的场景。
总结
Span<T> 是 .NET 平台的一项重要补充,它通过提供一种类型安全、内存安全且零拷贝的方式来访问连续内存区域,极大地提升了应用程序在处理大量数据时的性能。虽然它有一些使用限制(ref struct 的特性),但通过与 Memory<T> 结合使用,可以覆盖更广泛的场景。理解并正确使用 Span<T> 对于编写高性能的 C# 代码至关重要。