C# 内存效率:Span<T> 与 Memory<T> 实践
深入探讨 C# 如何利用 Span<T> 和 Memory<T> 实现连续内存的低成本安全访问,并通过高频字符串分析器的实例展示零堆内存分配的极致优化。
在编写高性能 C# 应用程序(如高并发 Web API、网关、解析器等)时,垃圾回收(GC)引起的暂停和 CPU 开销通常是主要性能瓶颈。传统的 C# 代码中存在大量隐式的临时数组拷贝和字符串分配。
为了解决这一痛点,.NET Core 开始引入 Span<T> 和 Memory<T>,为 C# 带来了接近 C++ 的底层内存操作效率,同时保持了托管代码的类型安全。
1. 痛点:堆分配与 GC 压力
假设我们需要解析一段来自网络传输的字符串,格式为 ID:Value,例如 "10023:JohnDoe"。我们需要分别提取 ID 和姓名:
string rawData = "10023:JohnDoe";
int colonIndex = rawData.IndexOf(':');
// 以下两行代码都会在堆上分配新的 string 对象!
string idStr = rawData.Substring(0, colonIndex);
string name = rawData.Substring(colonIndex + 1);
如果这个解析操作在每秒几万次的网络请求中被调用,那么 Substring 产生的几万个临时小对象就会堆满垃圾回收器的第 0 代(Gen 0),频繁触发 GC 暂停。
2. 认识 Span<T>:指向任意内存的窗口
Span<T> 是一个只读结构体(ref struct),它在底层只保存两个要素:
- 一个托管指针(指向数据起始位置)
- 一个长度(表示可用区域的大小)
它可以无缝地表达一段连续内存,无论这段内存是在:
- 托管堆(普通的数组或字符串)
- 线程栈(通过
stackalloc分配的内存) - 非托管堆(通过原生 C++ API 申请的指针)
最重要的是,对 Span<T> 的切片(Slicing)操作完全不会复制数据,它只是移动了内部的指针和长度。
// 零堆分配的高效切片
ReadOnlySpan<char> rawSpan = "10023:JohnDoe";
int colonIdx = rawSpan.IndexOf(':');
// 切片操作只移动指针,不复制任何数据,不产生堆分配!
ReadOnlySpan<char> idSpan = rawSpan.Slice(0, colonIdx);
ReadOnlySpan<char> nameSpan = rawSpan.Slice(colonIdx + 1);
3. ref struct 的限制与 Memory<T>
由于 Span<T> 被定义为 ref struct,它有一些严格的安全限制以确保它不会脱离所在的调用栈:
- 不能作为类或普通结构体的字段。
- 不能在异步方法中使用(因为异步的
await会跨越线程栈)。 - 不能被装箱。
为了在异步场景下(如从网络流中异步读取数据)也能利用这种机制,.NET 引入了 Memory<T>。
Memory<T> 不是 ref struct,它是一个普通结构体。它可以在堆上传输,可以作为字段存储,也可以跨越 await 边界:
// 可以在异步方法中传输
public async Task<int> ProcessDataAsync(Memory<byte> buffer)
{
// 在需要进行 CPU 计算或解析时,将其转换为 Span 使用
Span<byte> span = buffer.Span;
return await ReadFromNetworkAsync(buffer);
}
4. 实战演练:零堆分配的自定义分析器
下面我们编写一个解析复杂配置日志(例如 KEY=VALUE;KEY=VALUE)的高性能分析器。
传统做法(大量垃圾对象):
public Dictionary<string, string> ParseClassic(string config)
{
var dict = new Dictionary<string, string>();
var pairs = config.Split(';'); // 堆分配:数组
foreach (var pair in pairs)
{
var kv = pair.Split('='); // 堆分配:数组
dict[kv[0]] = kv[1]; // 堆分配:字符串
}
return dict;
}
高性能做法(使用 Span 和零分配):
public static void ParseHighPerformance(string config)
{
ReadOnlySpan<char> span = config;
while (!span.IsEmpty)
{
int separatorIndex = span.IndexOf(';');
ReadOnlySpan<char> pair = separatorIndex < 0
? span
: span.Slice(0, separatorIndex);
int equalsIndex = pair.IndexOf('=');
if (equalsIndex >= 0)
{
ReadOnlySpan<char> key = pair.Slice(0, equalsIndex);
ReadOnlySpan<char> value = pair.Slice(equalsIndex + 1);
// 直接对 key 和 value 的 Span 进行解析或判断,无需生成 string!
if (key.SequenceEqual("LogLevel"))
{
// 处理 LogLevel 的逻辑
}
}
if (separatorIndex < 0) break;
span = span.Slice(separatorIndex + 1);
}
}
在上述高性能做法中,整个解析过程没有调用任何一次内存分配(GC Allocation 为 0)。
小结
Span<T> 和 Memory<T> 彻底改变了 C# 处理密集型数据(如文件、网络流、文本解析)的方式。
在编写新代码时,建议养成以下习惯:
- 当编写接收只读字符/数组作为参数的实用工具函数时,优先使用
ReadOnlySpan<T>。 - 在跨异步逻辑传输数据缓冲区时,使用
Memory<T>。 - 对性能敏感的文本分析,使用
Span<char>代替常规的Split和Substring。
通过合理使用这些类型,你的 C# 应用程序能以极低的硬件资源占用支撑更高的并发请求。