MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C#Span<T>与Memory<T>高性能内存操作

2022-07-032.2k 阅读

C# 中的内存管理基础

在深入探讨 Span<T>Memory<T> 之前,我们先回顾一下 C# 中的内存管理基础。C# 是一种托管语言,这意味着它依赖于垃圾回收器(GC)来管理内存。当我们在 C# 中创建一个对象时,CLR(公共语言运行时)会在托管堆上分配内存。例如:

class MyClass
{
    public int Value { get; set; }
}

class Program
{
    static void Main()
    {
        MyClass obj = new MyClass();
        obj.Value = 42;
    }
}

在上述代码中,new MyClass() 语句在托管堆上为 MyClass 对象分配内存。垃圾回收器会在适当的时候回收不再使用的对象所占用的内存,这减轻了开发者手动管理内存的负担,但也带来了一些性能开销。

此外,C# 也支持非托管内存的操作,通过 unsafe 代码块和 fixed 关键字。例如:

unsafe class UnsafeExample
{
    public static void Main()
    {
        int[] numbers = new int[] { 1, 2, 3 };
        fixed (int* ptr = numbers)
        {
            *ptr = 10;
        }
        Console.WriteLine(numbers[0]); // 输出 10
    }
}

在这个例子中,fixed 关键字固定了数组在内存中的位置,以便我们可以获取其地址并进行直接的指针操作。然而,这种方式需要小心使用,因为不正确的指针操作可能导致内存泄漏或程序崩溃。

传统内存操作的局限性

  1. 托管内存操作的开销
    • 在处理大量数据时,托管内存的分配和垃圾回收会带来显著的性能开销。每次在托管堆上分配对象,CLR 都需要更新一些内部数据结构,如对象头信息等。垃圾回收器在回收内存时,也需要遍历托管堆,标记存活对象和垃圾对象,这一过程可能会暂停应用程序的执行,导致卡顿。
    • 例如,在一个循环中频繁创建和销毁对象:
class TempClass
{
    public byte[] Data { get; set; }
}

class Program
{
    static void Main()
    {
        for (int i = 0; i < 1000000; i++)
        {
            TempClass temp = new TempClass();
            temp.Data = new byte[1024];
            // 这里进行一些简单操作
            temp = null;
        }
    }
}

在这个例子中,大量的 TempClass 对象及其包含的字节数组在托管堆上频繁分配和销毁,垃圾回收器需要不断地清理这些对象,从而增加了程序的执行时间。

  1. 非托管内存操作的风险
    • 非托管内存操作虽然可以避免托管内存的一些开销,但它需要开发者手动管理内存的分配和释放。如果忘记释放内存,就会导致内存泄漏。另外,指针操作很容易出错,例如越界访问内存,这可能会导致程序崩溃或数据损坏。
    • 如下代码展示了一个潜在的内存泄漏场景:
unsafe class MemoryLeakExample
{
    public static void Main()
    {
        byte* buffer = (byte*)Marshal.AllocHGlobal(1024);
        // 使用 buffer
        // 这里忘记调用 Marshal.FreeHGlobal(new IntPtr(buffer))
    }
}

在这个例子中,Marshal.AllocHGlobal 分配了非托管内存,但没有调用 Marshal.FreeHGlobal 来释放内存,从而导致内存泄漏。

Span 深入解析

  1. Span 的定义与本质
    • Span<T> 是一个结构体,它提供了对一段连续内存的高效只读或读写访问。Span<T> 并不拥有它所指向的内存,它只是一个视图,这意味着它不会影响内存的生命周期。它可以指向栈上的内存、托管堆上的数组,甚至是非托管内存。
    • Span<T> 主要用于性能敏感的场景,例如字符串解析、数值计算等。它在栈上分配,因此没有堆分配的开销。其定义如下:
public readonly ref struct Span<T>
{
    // 内部实现
    private readonly void* _pointer;
    private readonly int _length;

    // 构造函数
    public Span(T[] array)
    {
        fixed (T* ptr = array)
        {
            _pointer = ptr;
            _length = array.Length;
        }
    }

    // 其他方法和属性
    public int Length => _length;
    public ref T this[int index] => ref Unsafe.Add(ref Unsafe.AsRef<T>(_pointer), index);
}
  1. Span 的创建方式
    • 从数组创建:可以直接从数组创建 Span<T>。例如:
int[] numbers = { 1, 2, 3, 4, 5 };
Span<int> numberSpan = new Span<int>(numbers);
- **栈上分配内存创建**:可以使用 `stackalloc` 关键字在栈上分配内存并创建 `Span<T>`。例如:
Span<int> stackSpan = stackalloc int[10];
for (int i = 0; i < stackSpan.Length; i++)
{
    stackSpan[i] = i * 2;
}
- **从现有 Span<T> 创建子 Span**:可以通过 `Slice` 方法从现有 `Span<T>` 创建子 `Span`。例如:
int[] numbers = { 1, 2, 3, 4, 5 };
Span<int> numberSpan = new Span<int>(numbers);
Span<int> subSpan = numberSpan.Slice(2, 2); // 包含 3 和 4
  1. Span 的操作方法
    • IndexOf 方法:用于查找指定元素在 Span<T> 中的索引。例如:
int[] numbers = { 1, 2, 3, 2, 4 };
Span<int> numberSpan = new Span<int>(numbers);
int index = numberSpan.IndexOf(2); // 返回 1
- **CopyTo 方法**:用于将一个 `Span<T>` 的内容复制到另一个 `Span<T>`。例如:
int[] sourceNumbers = { 1, 2, 3 };
int[] targetNumbers = new int[3];
Span<int> sourceSpan = new Span<int>(sourceNumbers);
Span<int> targetSpan = new Span<int>(targetNumbers);
sourceSpan.CopyTo(targetSpan);
- **Fill 方法**:用于将 `Span<T>` 中的所有元素设置为指定值。例如:
Span<int> fillSpan = stackalloc int[5];
fillSpan.Fill(10);
  1. Span 的性能优势
    • 避免堆分配:由于 Span<T> 可以在栈上分配,避免了在托管堆上分配内存的开销。例如,在处理大量临时数据时,使用 stackallocSpan<T> 可以显著提高性能。
    • 高效的内存访问Span<T> 提供了直接的内存访问方式,通过指针操作,减少了托管代码中的一些间接层,从而提高了访问速度。例如,在遍历 Span<T> 时,其性能比遍历普通数组略高。

Memory 深入解析

  1. Memory 的定义与本质
    • Memory<T> 也是一个结构体,它提供了对一段内存的抽象表示,与 Span<T> 类似,但 Memory<T> 更加灵活,它可以表示托管内存和非托管内存,并且可以跨异步边界使用。Memory<T> 可以通过 Memory<T>.Span 属性获取其对应的 Span<T>
    • Memory<T> 的定义如下:
public readonly struct Memory<T>
{
    private readonly object? _obj;
    private readonly int _start;
    private readonly int _length;

    // 构造函数
    public Memory(T[] array)
    {
        _obj = array;
        _start = 0;
        _length = array.Length;
    }

    // 属性
    public Span<T> Span
    {
        get
        {
            if (_obj is T[] array)
            {
                fixed (T* ptr = array)
                {
                    return new Span<T>(Unsafe.Add(ref Unsafe.AsRef<T>(ptr), _start), _length);
                }
            }
            throw new InvalidOperationException();
        }
    }
}
  1. Memory 的创建方式
    • 从数组创建:与 Span<T> 类似,可以从数组创建 Memory<T>。例如:
int[] numbers = { 1, 2, 3 };
Memory<int> numberMemory = new Memory<int>(numbers);
- **通过 MemoryPool<T> 创建**:`MemoryPool<T>` 是一个内存池,用于高效地管理内存的分配和回收。可以通过 `MemoryPool<T>.Rent` 方法从内存池获取 `Memory<T>`。例如:
MemoryPool<int> pool = MemoryPool<int>.Shared;
Memory<int> rentedMemory = pool.Rent(1024);
try
{
    Span<int> span = rentedMemory.Span;
    // 使用 span
}
finally
{
    rentedMemory.Dispose();
    pool.Return(rentedMemory);
}
  1. Memory 的操作方法
    • Slice 方法:与 Span<T>Slice 方法类似,用于创建子 Memory<T>。例如:
int[] numbers = { 1, 2, 3, 4, 5 };
Memory<int> numberMemory = new Memory<int>(numbers);
Memory<int> subMemory = numberMemory.Slice(2, 2);
- **ToArray 方法**:用于将 `Memory<T>` 的内容复制到一个新的数组中。例如:
int[] numbers = { 1, 2, 3 };
Memory<int> numberMemory = new Memory<int>(numbers);
int[] newArray = numberMemory.ToArray();
  1. Memory 与异步操作
    • Memory<T> 特别适合异步操作,因为它可以跨异步边界传递,而 Span<T> 由于其栈分配的特性,不能在异步方法之间传递。例如,在网络编程中,Memory<T> 可以用于接收和发送数据。
class NetworkExample
{
    private readonly MemoryStream _memoryStream = new MemoryStream();

    public async Task WriteDataAsync(Memory<byte> data)
    {
        await _memoryStream.WriteAsync(data.Span);
    }

    public async Task<Memory<byte>> ReadDataAsync(int length)
    {
        Memory<byte> buffer = new Memory<byte>(new byte[length]);
        await _memoryStream.ReadAsync(buffer.Span);
        return buffer;
    }
}

Span 与 Memory 的对比

  1. 内存所有权与生命周期
    • Span<T> 不拥有内存,它只是一个内存视图,其生命周期取决于它所指向的内存的生命周期。例如,如果 Span<T> 指向一个栈上分配的数组,当栈帧结束时,Span<T> 所指向的内存就会无效。
    • Memory<T> 也不直接拥有内存,但它提供了更灵活的内存管理方式。例如,通过 MemoryPool<T> 获取的 Memory<T>,其内存的生命周期由内存池管理,直到调用 Return 方法将内存返回给内存池。
  2. 跨异步边界
    • Span<T> 不能跨异步边界传递,因为它在栈上分配,而异步操作可能会导致栈的重新创建。如果在异步方法之间传递 Span<T>,会导致编译错误。
    • Memory<T> 可以跨异步边界传递,这使得它在异步编程场景中非常有用,如网络通信、文件 I/O 等。
  3. 性能与适用场景
    • 在性能敏感且不需要跨异步边界的场景下,Span<T> 是更好的选择,因为它在栈上分配,避免了堆分配的开销,并且提供了高效的内存访问方式。例如,在字符串解析、数值计算等场景中,Span<T> 可以显著提高性能。
    • 在需要跨异步边界或者需要更灵活的内存管理的场景下,Memory<T> 更为合适。例如,在网络编程、异步文件操作等场景中,Memory<T> 可以更好地满足需求。

实际应用场景

  1. 字符串解析
    • 在字符串解析中,Span<char> 可以提供高效的处理方式。例如,解析 CSV 文件中的一行数据:
string csvLine = "123,abc,456";
Span<char> lineSpan = csvLine.AsSpan();
int index = lineSpan.IndexOf(',');
Span<char> firstPart = lineSpan.Slice(0, index);
int value = int.Parse(firstPart);
  1. 数值计算
    • 在进行数值计算时,Span<T> 可以提高性能。例如,计算数组元素的总和:
int[] numbers = { 1, 2, 3, 4, 5 };
Span<int> numberSpan = new Span<int>(numbers);
int sum = 0;
foreach (int num in numberSpan)
{
    sum += num;
}
  1. 网络编程
    • 在网络编程中,Memory<T> 可以用于高效地处理网络数据的接收和发送。例如,使用 Socket 类接收数据:
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
Memory<byte> buffer = new Memory<byte>(new byte[1024]);
int bytesRead = socket.Receive(buffer.Span);
  1. 文件 I/O
    • 在文件 I/O 操作中,Memory<T> 也可以发挥作用。例如,异步读取文件内容:
using FileStream fileStream = new FileStream("example.txt", FileMode.Open, FileAccess.Read);
Memory<byte> buffer = new Memory<byte>(new byte[1024]);
await fileStream.ReadAsync(buffer.Span);

注意事项与最佳实践

  1. 避免不必要的内存分配
    • 在使用 Span<T>Memory<T> 时,尽量避免在操作过程中产生额外的堆分配。例如,在处理 Span<T> 时,避免使用会导致新对象创建的方法,如 ToString 等。
  2. 正确处理内存生命周期
    • 对于 Memory<T>,特别是从 MemoryPool<T> 获取的 Memory<T>,一定要在使用完毕后正确释放内存,通过调用 Return 方法将其返回给内存池,以避免内存泄漏。
  3. 注意跨异步边界的使用
    • 如果需要在异步方法之间传递内存数据,一定要使用 Memory<T>,而不是 Span<T>。同时,要注意在异步操作中对 Memory<T> 的正确处理,确保数据的完整性。
  4. 性能测试与优化
    • 在实际应用中,应该对使用 Span<T>Memory<T> 的代码进行性能测试,与传统的内存操作方式进行对比,以确定是否真正提高了性能。根据测试结果,对代码进行进一步的优化。例如,可以调整 MemoryPool<T> 的参数,以优化内存池的性能。

总之,Span<T>Memory<T> 为 C# 开发者提供了强大的高性能内存操作工具,通过深入理解它们的本质、使用方法和适用场景,并遵循最佳实践,开发者可以显著提升应用程序的性能和效率。无论是在字符串处理、数值计算还是网络和文件 I/O 等领域,Span<T>Memory<T> 都有着广泛的应用前景。在实际项目中,合理地运用这些工具,可以让我们的代码在性能上更上一层楼。