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),它在底层只保存两个要素:

  1. 一个托管指针(指向数据起始位置)
  2. 一个长度(表示可用区域的大小)

它可以无缝地表达一段连续内存,无论这段内存是在:

  • 托管堆(普通的数组或字符串)
  • 线程栈(通过 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> 代替常规的 SplitSubstring

通过合理使用这些类型,你的 C# 应用程序能以极低的硬件资源占用支撑更高的并发请求。