• C#中Span<T>特性的高级应用
  • 发布于 1个月前
  • 72 热度
    0 评论
  • 我怕黑
  • 22 粉丝 53 篇博客
  •   
简介
Span 是一个结构类型(struct),在 C# 7.2 中作为 System 命名空间下的 Span<T> 结构引入。它的设计目标是表示一块连续的任意内存区域。与数组或集合不同,Span 并不拥有它所指向的内存区域,而是为现有的内存块提供了一个轻量级的视图。这种特性使 Span 在需要高效处理内存缓冲区的场景中尤其强大,同时避免了额外的开销和不安全代码的使用。

Span 的关键特性
非拥有性质
Span 是一种非拥有类型,这意味着它不会分配或释放托管内存或非托管内存。它操作的是现有的内存块,因此在内存所有权由其他地方管理或在多个组件之间共享的场景下,Span 是一个理想的选择。

连续内存
Span 表示一段连续的内存区域。由于这种连续性,Span 可以与其他基于内存的结构(如数组、指针和本机互操作场景)无缝交互。

性能优势
Span 的非拥有和连续性特点使其具有显著的性能优势。由于它不涉及内存分配或复制,使用 Span 可以让代码执行更高效、更快速。

零成本抽象
Span 的设计原则之一是提供零成本抽象。这意味着在代码中使用 Span 不会引入任何运行时开销,因此适用于对性能要求极高的场景。

ReadOnlySpan 的使用
在需要避免不必要的字符串分配并提升性能的场景中,ReadOnlySpan 是一个更好的选择,尤其是在处理大型字符串或执行子字符串操作时。ReadOnlySpan<char> 在需要只读访问字符串的某一部分且无需创建新的字符串对象时非常有用。以下是一些常见用法的介绍:

1. 从字符串创建 ReadOnlySpan
通过 AsSpan 方法可以轻松从字符串创建一个 ReadOnlySpan<char>。
string originalString = "Hello, World!";
ReadOnlySpan<char> spanFromString = originalString.AsSpan();
2. 使用子字符串
与 Substring 不同,可以使用 Slice 方法操作 ReadOnlySpan<char>。
ReadOnlySpan<char> substringSpan = spanFromString.Slice(startIndex, length);
3. 将子字符串传递给方法
在将子字符串传递给方法时,可以使用 ReadOnlySpan<char> 代替普通的字符串。
void ProcessSubstring(ReadOnlySpan<char> substring)
{
    // 对子字符串进行操作
}
// 堆代码 duidaima.com
// 调用
ProcessSubstring(spanFromString.Slice(startIndex, length));
4. 在字符串中搜索
可以在 ReadOnlySpan<char> 上使用 IndexOf 方法进行搜索。
int index = spanFromString.IndexOf('W');
5. 使用内存映射文件
在处理大文件时(例如内存映射文件),使用 ReadOnlySpan<char> 更加高效。
using (MemoryMappedFile mmf = MemoryMappedFile.CreateFromFile("largeFile.txt"))
{
    using (MemoryMappedViewAccessor accessor = mmf.CreateViewAccessor())
    {
        long fileSize = new FileInfo("largeFile.txt").Length;
        ReadOnlySpan<byte> fileData = accessor.ReadArray(0, (int)fileSize).Span;

        // 以 ReadOnlySpan<byte> 处理 fileData
    }
}
6. 高效字符串操作
在某些场景中,ReadOnlySpan<char> 可以用于高效地处理字符串操作。
// 在子字符串中替换字符而不创建新字符串
spanFromString.Slice(startIndex, length).CopyTo(newSpan);
7. 将子字符串传递给 API
某些 API 出于性能考虑可能会接受 ReadOnlySpan<char>。例如,在使用操作字符范围的外部库或 API 时。
void ExternalApiMethod(ReadOnlySpan<char> data)
{
    // 使用字符范围调用外部 API
}

// 使用示例
ExternalApiMethod(spanFromString.Slice(startIndex, length));
ReadOnlySpan<char> 提供了一种更高效处理字符串的方式,尤其是在需要尽量减少内存分配和复制的场景下。它是优化性能关键代码的强大工具,在处理大量字符串数据时尤为有用。

Span 的局限性
虽然 C# 的 Span 功能强大且优势明显,但它在处理连续和非连续内存缓冲区时也存在一些局限性和需要注意的事项。以下是这些局限性详解。
连续内存缓冲区
1.内存所有权
Span 是一种非拥有类型,它不拥有它所指向的内存。这意味着你需要确保在 Span 的生命周期内,底层内存或非托管内存保持有效。如果内存实例被释放或变得无效,继续使用 Span 会导致未定义行为。

2.不可变字符串
虽然 Span 被设计用于高效操作可变内存,但 C# 的字符串是不可变的。将字符串转换为 Span<char> 时,可能会引发意外问题,尤其是在尝试修改字符串内容时。

3.数组边界检查
虽然 Span 本身提供了零成本抽象,但对 Span 的操作并未消除数组边界检查。这意味着在通过 Span 访问元素时,运行时仍会进行数组边界检查,与使用不安全指针相比可能带来轻微的性能开销。

4.垃圾回收的影响
如果你在数组上创建了一个 Span,而该数组被垃圾回收器回收,那么随后使用 Span 会导致未定义行为。这是因为底层内存可能已被回收,而通过 Span 访问它可能会导致访问无效内存。

5.某些 API 的兼容性
一些 API 或库可能不直接支持 Span,尤其是较旧的或未设计为支持 Span 的第三方库。在这些情况下,你可能需要在 Span 和其他类型(如数组或指针)之间进行转换。

非连续内存缓冲区
1.对非连续内存的有限支持
Span 主要设计用于处理连续内存缓冲区或块。在需要处理非连续内存缓冲区或具有内存间隙的结构时,Span 可能不是最合适的选择。

2.结构化局限性
某些数据结构或涉及非连续内存的场景可能不适合 Span。例如,链表或图结构可能无法满足 Span 对连续内存的要求。

3.复杂指针操作
在处理非连续内存时,尤其是需要复杂指针运算的场景,Span 可能无法提供与 C++ 中原生指针相同的底层控制和灵活性。在这些情况下,使用不安全代码和指针可能会更适合。

4.某些 API 的直接支持不足
与连续内存类似,某些 API 或库可能不直接支持通过 Span 表示的非连续内存。这种情况下可能需要额外的中间步骤或转换。

Span 与非托管内存
在 C# 中,Span 可以高效地与非托管内存结合使用,以一种受控且高效的方式执行内存相关操作。非托管内存是指不受 .NET 运行时垃圾回收器管理的内存,通常涉及原生内存的分配和释放。以下是如何在 C# 中使用 Span 操作非托管内存的示例:

分配非托管内存
可以使用 System.Runtime.InteropServices 命名空间下的 Marshal 类来分配非托管内存。Marshal.AllocHGlobal 方法用于分配非托管内存并返回分配块的指针。分配的内存区域具有读写权限,并且可以通过 Span 轻松访问。
using System;
using System.Runtime.InteropServices;

class Program
{
    static void Main()
    {
        const int bufferSize = 100;
        IntPtr unmanagedMemory = Marshal.AllocHGlobal(bufferSize);

        // 从非托管内存创建 Span
        Span<byte> span = new Span<byte>(unmanagedMemory.ToPointer(), bufferSize);

        // 根据需要使用 Span...
        // 堆代码 duidaima.com
        // 完成后不要忘记释放非托管内存
        Marshal.FreeHGlobal(unmanagedMemory);
    }
}
在这个例子中,我们使用 Marshal.AllocHGlobal 分配了一块非托管内存,并通过获取的指针创建了一个 Span<byte>。这样可以利用 Span 的 API 操作非托管内存。

复制数据到非托管内存或从非托管内存中复制数据
Span 提供了 Slice、CopyTo 和 ToArray 等方法,用于在托管和非托管内存之间高效地复制数据。
using System;
using System.Runtime.InteropServices;

class Program
{
    static void Main()
    {
        const int bufferSize = 100;
        IntPtr unmanagedMemory = Marshal.AllocHGlobal(bufferSize);

        // 从非托管内存创建 Span
        Span<byte> span = new Span<byte>(unmanagedMemory.ToPointer(), bufferSize);

        // 将数据复制到非托管内存
        byte[] dataToCopy = { 1, 2, 3, 4, 5 };
        dataToCopy.AsSpan().CopyTo(span);

        // 从非托管内存复制数据
        byte[] copiedData = span.ToArray();

        // 完成后不要忘记释放非托管内存
        Marshal.FreeHGlobal(unmanagedMemory);
    }
}
在这个示例中,我们通过 CopyTo 将托管数组的数据复制到非托管内存,随后通过 ToArray 将数据从非托管内存复制回托管数组。这种方法在需要在托管和非托管内存之间高效传输数据时非常有用。

使用不安全代码
在处理非托管内存时,也可以结合不安全代码使用指针。在这种情况下,可以通过 GetPinnableReference 方法从 Span 中获取指针。
using System;
using System.Runtime.InteropServices;

class Program
{
    static void Main()
    {
        const int bufferSize = 100;
        IntPtr unmanagedMemory = Marshal.AllocHGlobal(bufferSize);

        // 从非托管内存创建 Span
        Span<byte> span = new Span<byte>(unmanagedMemory.ToPointer(), bufferSize);

        // 使用不安全代码处理指针
        unsafe
        {
            byte* pointer = (byte*)Unsafe.AsPointer(ref MemoryMarshal.GetReference(span));

            // 根据需要使用指针...
        }

        // 别忘了释放非托管内存
        Marshal.FreeHGlobal(unmanagedMemory);
    }
}
在上述示例中,我们通过 Unsafe.AsPointer 方法从 Span 中获取一个指针。这使得我们可以直接使用不安全代码对内存进行操作。

注意:在处理非托管内存时,务必正确管理内存的分配与释放,以避免内存泄漏。同时,使用不安全代码时需要格外小心,因为一旦操作不当,可能会引发安全风险。

Span 与异步方法调用
将 Span 与 C# 中的异步方法结合使用是一个强大的组合,特别是在处理大量数据或 I/O 操作时,可以有效避免数据的额外拷贝。以下是一些常见场景:

1. 异步 I/O 操作
在异步读取或写入流数据时,可以使用 Memory<T> 或 Span<T> 高效地操作数据,避免创建额外的缓冲区。
async Task ProcessDataAsync(Stream stream)
{
    const int bufferSize = 4096;
    byte[] buffer = new byte[bufferSize];

    while (true)
    {
        int bytesRead = await stream.ReadAsync(buffer.AsMemory());

        if (bytesRead == 0)
            break;

        // 使用 Span 直接处理数据,避免不必要的拷贝
        ProcessData(buffer.AsSpan(0, bytesRead));
    }
}

void ProcessData(Span<byte> data)
{
    // 对数据执行操作
}
在这个例子中,ReadAsync 方法异步读取流中的数据到缓冲区中,ProcessData 方法直接从 Span<byte> 中处理数据,无需额外拷贝。

2. 异步文件操作
类似于 I/O 操作,在处理文件的异步操作时,也可以使用 Span 高效地处理数据。
async Task ProcessFileAsync(string filePath)
{
    const int bufferSize = 4096;
    using (FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
    {
        byte[] buffer = new byte[bufferSize];
        while (true)
        {
            int bytesRead = await fileStream.ReadAsync(buffer.AsMemory());
            if (bytesRead == 0)
                break;
            // 使用 Span 直接处理数据,避免不必要的拷贝
            ProcessData(buffer.AsSpan(0, bytesRead));
        }
    }
}
void ProcessData(Span<byte> data)
{
    // 对数据执行操作
}
在这个示例中,ReadAsync 从文件流中读取数据到缓冲区中,然后 ProcessData 方法直接从 Span<byte> 中处理数据,避免了数据拷贝。

3. 异步任务处理
当处理产生或消费数据的异步任务时,可以使用 Memory<T> 或 Span<T> 来避免不必要的拷贝。
async Task<int> ProcessDataAsync(int[] data)
{
    // 异步处理数据
    await Task.Delay(1000);

    // 返回已处理数据的长度
    return data.Length;
}
async Task Main()
{
    int[] inputData = Enumerable.Range(1, 1000).ToArray();
    // 异步处理数据,无需拷贝
    int processedLength = await ProcessDataAsync(inputData.AsMemory());

    Console.WriteLine($"Processed data length: {processedLength}");
}
在此示例中,ProcessDataAsync 异步处理数据并返回数据的长度,而无需创建额外的数据副本。

总结
Span 是 C# 中一个强大的工具,它提供了一种高效的内存操作方式,特别适合在需要最小化内存分配和拷贝的场景中使用。由于其非拥有型和连续内存的特点,Span 在从字符串操作到高性能数值处理等多种应用中表现尤为出色。通过正确使用 Span,开发者可以显著优化代码性能,为构建高效、健壮的应用奠定基础。随着 C# 的不断演进,Span 无疑是优化代码的重要工具。
用户评论