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
关键字固定了数组在内存中的位置,以便我们可以获取其地址并进行直接的指针操作。然而,这种方式需要小心使用,因为不正确的指针操作可能导致内存泄漏或程序崩溃。
传统内存操作的局限性
- 托管内存操作的开销
- 在处理大量数据时,托管内存的分配和垃圾回收会带来显著的性能开销。每次在托管堆上分配对象,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
对象及其包含的字节数组在托管堆上频繁分配和销毁,垃圾回收器需要不断地清理这些对象,从而增加了程序的执行时间。
- 非托管内存操作的风险
- 非托管内存操作虽然可以避免托管内存的一些开销,但它需要开发者手动管理内存的分配和释放。如果忘记释放内存,就会导致内存泄漏。另外,指针操作很容易出错,例如越界访问内存,这可能会导致程序崩溃或数据损坏。
- 如下代码展示了一个潜在的内存泄漏场景:
unsafe class MemoryLeakExample
{
public static void Main()
{
byte* buffer = (byte*)Marshal.AllocHGlobal(1024);
// 使用 buffer
// 这里忘记调用 Marshal.FreeHGlobal(new IntPtr(buffer))
}
}
在这个例子中,Marshal.AllocHGlobal
分配了非托管内存,但没有调用 Marshal.FreeHGlobal
来释放内存,从而导致内存泄漏。
Span 深入解析
- 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);
}
- 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
- Span 的操作方法
- IndexOf 方法:用于查找指定元素在
Span<T>
中的索引。例如:
- IndexOf 方法:用于查找指定元素在
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);
- Span 的性能优势
- 避免堆分配:由于
Span<T>
可以在栈上分配,避免了在托管堆上分配内存的开销。例如,在处理大量临时数据时,使用stackalloc
和Span<T>
可以显著提高性能。 - 高效的内存访问:
Span<T>
提供了直接的内存访问方式,通过指针操作,减少了托管代码中的一些间接层,从而提高了访问速度。例如,在遍历Span<T>
时,其性能比遍历普通数组略高。
- 避免堆分配:由于
Memory 深入解析
- 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();
}
}
}
- 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);
}
- Memory 的操作方法
- Slice 方法:与
Span<T>
的Slice
方法类似,用于创建子Memory<T>
。例如:
- Slice 方法:与
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();
- 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 的对比
- 内存所有权与生命周期
Span<T>
不拥有内存,它只是一个内存视图,其生命周期取决于它所指向的内存的生命周期。例如,如果Span<T>
指向一个栈上分配的数组,当栈帧结束时,Span<T>
所指向的内存就会无效。Memory<T>
也不直接拥有内存,但它提供了更灵活的内存管理方式。例如,通过MemoryPool<T>
获取的Memory<T>
,其内存的生命周期由内存池管理,直到调用Return
方法将内存返回给内存池。
- 跨异步边界
Span<T>
不能跨异步边界传递,因为它在栈上分配,而异步操作可能会导致栈的重新创建。如果在异步方法之间传递Span<T>
,会导致编译错误。Memory<T>
可以跨异步边界传递,这使得它在异步编程场景中非常有用,如网络通信、文件 I/O 等。
- 性能与适用场景
- 在性能敏感且不需要跨异步边界的场景下,
Span<T>
是更好的选择,因为它在栈上分配,避免了堆分配的开销,并且提供了高效的内存访问方式。例如,在字符串解析、数值计算等场景中,Span<T>
可以显著提高性能。 - 在需要跨异步边界或者需要更灵活的内存管理的场景下,
Memory<T>
更为合适。例如,在网络编程、异步文件操作等场景中,Memory<T>
可以更好地满足需求。
- 在性能敏感且不需要跨异步边界的场景下,
实际应用场景
- 字符串解析
- 在字符串解析中,
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);
- 数值计算
- 在进行数值计算时,
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;
}
- 网络编程
- 在网络编程中,
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);
- 文件 I/O
- 在文件 I/O 操作中,
Memory<T>
也可以发挥作用。例如,异步读取文件内容:
- 在文件 I/O 操作中,
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);
注意事项与最佳实践
- 避免不必要的内存分配
- 在使用
Span<T>
和Memory<T>
时,尽量避免在操作过程中产生额外的堆分配。例如,在处理Span<T>
时,避免使用会导致新对象创建的方法,如ToString
等。
- 在使用
- 正确处理内存生命周期
- 对于
Memory<T>
,特别是从MemoryPool<T>
获取的Memory<T>
,一定要在使用完毕后正确释放内存,通过调用Return
方法将其返回给内存池,以避免内存泄漏。
- 对于
- 注意跨异步边界的使用
- 如果需要在异步方法之间传递内存数据,一定要使用
Memory<T>
,而不是Span<T>
。同时,要注意在异步操作中对Memory<T>
的正确处理,确保数据的完整性。
- 如果需要在异步方法之间传递内存数据,一定要使用
- 性能测试与优化
- 在实际应用中,应该对使用
Span<T>
和Memory<T>
的代码进行性能测试,与传统的内存操作方式进行对比,以确定是否真正提高了性能。根据测试结果,对代码进行进一步的优化。例如,可以调整MemoryPool<T>
的参数,以优化内存池的性能。
- 在实际应用中,应该对使用
总之,Span<T>
和 Memory<T>
为 C# 开发者提供了强大的高性能内存操作工具,通过深入理解它们的本质、使用方法和适用场景,并遵循最佳实践,开发者可以显著提升应用程序的性能和效率。无论是在字符串处理、数值计算还是网络和文件 I/O 等领域,Span<T>
和 Memory<T>
都有着广泛的应用前景。在实际项目中,合理地运用这些工具,可以让我们的代码在性能上更上一层楼。