C# Span 详解 – wiki大全

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> 出现之前,处理内存通常涉及以下几种情况:

  1. 数组和子数组:当需要处理数组的一部分时,通常会创建数组的副本,这会导致额外的内存分配和复制开销。例如,byte[] subarray = new byte[length]; Array.Copy(originalArray, offset, subarray, 0, length);
  2. 字符串操作:字符串的子串操作也会创建新的字符串对象,导致内存分配。
  3. 非托管内存:与非托管内存交互时,通常需要使用 unsafe 代码和指针,这增加了复杂性和潜在的内存安全问题。
  4. Stackalloc:虽然可以在栈上分配内存,但 stackalloc 返回的是指针,使用起来不方便且不安全。

Span<T> 的引入解决了这些问题,它允许你:

  • 避免内存复制Span<T> 提供了对现有内存的“视图”,而不是创建副本。这显著减少了内存分配和垃圾回收的压力,从而提高了应用程序的性能。
  • 统一的内存访问:无论内存来自数组、字符串、栈还是非托管内存,Span<T> 都能提供统一的、类型安全的方式来访问它。
  • 内存安全:即使是在处理指针的情况下,Span<T> 也提供了边界检查,防止越界访问,从而减少了常见的内存错误。
  • stackalloc 结合Span<T> 可以很好地与 stackalloc 结合使用,从而在栈上分配内存并以类型安全的方式访问它,避免了 unsafe 关键字。

如何创建 Span<T>

Span<T> 可以从多种数据源创建:

  1. 从数组创建
    csharp
    byte[] byteArray = new byte[100];
    Span<byte> span = new Span<byte>(byteArray);
    // 或者从数组的一部分创建
    Span<byte> subSpan = new Span<byte>(byteArray, 10, 20); // 从索引10开始,长度为20

  2. stackalloc 内存创建
    csharp
    Span<byte> stackSpan = stackalloc byte[128]; // 只能在方法内部使用

  3. string 创建 (ReadOnlySpan<char>)
    由于字符串是不可变的,所以只能创建 ReadOnlySpan<char>
    csharp
    string myString = "Hello, World!";
    ReadOnlySpan<char> charSpan = myString.AsSpan();
    // 从字符串的一部分创建
    ReadOnlySpan<char> subCharSpan = myString.AsSpan(7, 5); // "World"

  4. 从指针和长度创建(需要 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 span = data.AsSpan();

// 索引访问
Console.WriteLine(span[0]); // 输出 1

// 修改 Span 的内容会修改原始内存
span[0] = 10;
Console.WriteLine(data[0]); // 输出 10

// 切片 (Slice) – 创建一个新的 Span,指向原始内存的子区域
Span subSpan = span.Slice(1, 3); // 包含元素 2, 3, 4 (或原始数组的 2,3,4)
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# 代码至关重要。

滚动至顶部