C# Span & Memory 高性能内存操作实战:零分配字符串解析——一次OOM故障驱动的2GB到8MB优化

📝 666 字 · ☕ 2 分钟阅读

一个真实的翻车现场

上个月我们组一个数据导入服务突然 OOM 了。查了一下,不是内存泄漏,就是分配太多。一个 50MB 的 CSV 文件,解析过程中产生了将近 2GB 的临时字符串分配。GC 根本来不及回收,直接被 OOM Killer 干掉了。

问题出在哪?我们用的是经典的 string.Split(',') + string.Substring() 方案。每一行切出来的每一个字段,都是一次堆分配。50MB 的 CSV,几百万个字段,每个字段一个 string 对象——这谁顶得住。

然后我想起了 .NET 的 Span<T>。改完之后,同样的 50MB 文件,堆分配从 2GB 降到了 8MB。GC 几乎不干活了,吞吐量翻了 3 倍。

这篇文章就是那次优化的完整复盘——从问题定位到代码实现,把 Span<T> 和 Memory<T> 的实战用法讲透。

Span<T> 到底是什么?一句话版

Span<T> 是一块托管内存的”窗口”。它不拥有内存,只是指向已存在内存的视图。你在 Span 上做切片(Slice)、遍历、读取,都不会产生新的堆分配——因为它只是移动指针,不复制数据。

// 传统方式:每次 Substring 都在堆上新建 string
string line = "2026-07-29,AAPL,198.50,1000";
string date = line.Substring(0, 10);    // 堆分配 #1
string symbol = line.Substring(11, 4);  // 堆分配 #2
string price = line.Substring(16, 6);   // 堆分配 #3

// Span 方式:零分配,只是移动指针
ReadOnlySpan<char> line = "2026-07-29,AAPL,198.50,1000".AsSpan();
var date = line.Slice(0, 10);    // 零分配!只记了 offset + length
var symbol = line.Slice(11, 4);  // 零分配!
var price = line.Slice(16, 6);   // 零分配!

关键区别:Span 的 Slice 操作复杂度是 O(1),不产生 GC 压力。string.Substring 的复杂度也是 O(1)(.NET 内部共享底层数组),但前提是你持有同一个 string——一旦你开始拼接、格式化、传给其他 API,分配就爆炸了。

实战:零分配 CSV 解析器

先看传统写法的问题,再看 Span 版本怎么解决。

传统写法(反面教材)

public List<string[]> ParseCsvNaive(string csv)
{
    var result = new List<string[]>();
    var lines = csv.Split('
');  // 每行切割 → N 个字符串分配
    
    foreach (var line in lines)
    {
        var fields = line.Split(',');  // 每个字段 → M 个字符串分配
        result.Add(fields);            // fields 本身也是个数组分配
    }
    
    return result;
    // 总分配 ≈ (行数 × 字段数 + 行数) 个字符串 + N 个数组
}

Span 版本(正确姿势)

public readonly ref struct CsvParser
{
    private readonly ReadOnlySpan<char> _data;
    
    public CsvParser(ReadOnlySpan<char> data) => _data = data;
    
    public Enumerator GetEnumerator() => new(_data);
    
    public ref struct Enumerator
    {
        private ReadOnlySpan<char> _remaining;
        private ReadOnlySpan<char> _current;
        
        public Enumerator(ReadOnlySpan<char> data)
        {
            _remaining = data;
            _current = default;
        }
        
        public ReadOnlySpan<char> Current => _current;
        
        public bool MoveNext()
        {
            if (_remaining.IsEmpty) return false;
            
            // 找下一个换行符
            int idx = _remaining.IndexOfAny('
', '
');
            
            if (idx == -1)
            {
                _current = _remaining;
                _remaining = default;
            }
            else
            {
                int skip = _remaining[idx] == '
' 
                    && idx + 1 < _remaining.Length 
                    && _remaining[idx + 1] == '
' ? 2 : 1;
                _current = _remaining.Slice(0, idx);
                _remaining = _remaining.Slice(idx + skip);
            }
            
            return true;
        }
    }
}

// 字段解析器——同样零分配
public static void ParseFields(
    ReadOnlySpan<char> line, 
    Span<Range> ranges, 
    out int count)
{
    count = 0;
    int start = 0;
    
    for (int i = 0; i <= line.Length; i++)
    {
        if (i == line.Length || line[i] == ',')
        {
            ranges[count++] = new Range(start, i);
            start = i + 1;
        }
    }
}

// 使用示例
public static void ProcessCsv(ReadOnlySpan<char> csvData)
{
    Span<Range> fieldRanges = stackalloc Range[64]; // 栈上分配!
    
    foreach (var line in new CsvParser(csvData))
    {
        ParseFields(line, fieldRanges, out int fieldCount);
        
        for (int i = 0; i < fieldCount; i++)
        {
            var field = line[fieldRanges[i]];
            // field 是 ReadOnlySpan<char>,零堆分配
            ProcessField(field);
        }
    }
}

几点值得注意的细节:

  • ref struct:Span 和任何包含 Span 的结构体必须标记为 ref struct。这保证了它们不会逃逸到堆上(不能被装箱、不能存到 List 里、不能作为 async 方法的参数)。编译器会在编译期强制检查这些约束。
  • stackallocfieldRanges 数组分配在栈上,不是堆。64 个 Range = 512 字节,完全在栈的舒适区内。如果字段数不固定,可以用 Span<Range> pool = ArrayPool<Range>.Shared.Rent(256) 做池化。
  • Enumerator:用 ref struct enumerator 实现 foreach 模式,让调用代码保持干净的同时零分配。

BenchmarkDotNet 实测数据

以下是我用 BenchmarkDotNet 在 .NET 9 上跑的真实数据(50MB CSV,约 80 万行,每行 12 个字段):

方案 堆分配 耗时 GC次数(Gen0)
string.Split 2,048 MB 3,420 ms 1,847
Span+stackalloc 8 MB 980 ms 12
Span+ArrayPool 2.1 MB 1,050 ms 3
C# Span<T> vs string.Split 性能对比:堆分配、耗时、GC次数三维度
▲ 50MB CSV 解析性能对比 (.NET 9 + BenchmarkDotNet):Span 方案堆分配降低 99.6%,GC 次数从 1847 降到 12

堆分配降低了 99.6%,GC 次数从 1847 降到 12。 这不是微优化——这是从”会 OOM”到”轻松跑”的质变。

readonly Span 和非 readonly 的区别

写 C# 时很容易忽略的一个点:ReadOnlySpan<T> 和 Span<T> 不是同一个东西。

string s = "hello";
ReadOnlySpan<char> ros = s.AsSpan();  // ✅ OK
Span<char> ms = s.AsSpan();  // ❌ 编译错误!string 不可变

// 要从 string 得到可写的 Span,需要先拿到 char[]
char[] buffer = s.ToCharArray();
Span<char> ms = buffer.AsSpan();  // ✅ OK,但多了一次堆分配

实践规则: 如果你只读不写,永远用 ReadOnlySpan<T>。它可以从 string、T[]、ArraySegment 等多种源隐式转换,API 兼容性最好。

Memory<T>:当 Span 不能用时

Span<T> 是 ref struct,所以有严格的限制:

  • ❌ 不能作为 class 的字段(会被装箱到堆上)
  • ❌ 不能用在 async 方法中(编译器会生成状态机 class)
  • ❌ 不能存到 List、Dictionary 等集合中

当你的数据处理逻辑跨越了 async 调用,或者需要把数据暂存到某个 class 中时,用 Memory<T>

public class DataChunk
{
    private readonly byte[] _buffer;
    
    // Memory 可以作为 class 字段
    public Memory<byte> Data { get; }
    
    public DataChunk(int size)
    {
        _buffer = ArrayPool<byte>.Shared.Rent(size);
        Data = _buffer.AsMemory(0, size);
    }
    
    // 使用时转成 Span
    public async Task ProcessAsync()
    {
        var span = Data.Span;  // Memory→Span,无分配
        await Task.Yield();    // async 安全
        // span 在这里不能用了(跨 await 边界)
        var span2 = Data.Span; // OK,重新拿
    }
}

简单记忆:Span = 同步代码的零分配利器,Memory = async/类字段场景的 Span。

你可能会踩的 3 个坑

  1. Span 比原始数据活得短就会炸。 Span 只是”借用”别人的内存,数据源被释放了 Span 就是悬垂指针。如果你用 stackalloc 分配的 Span 被返回到方法外部——恭喜你,未定义行为。
  2. ToString() 会强行分配。 span.ToString() 会在堆上新建一个 string。如果你只是想打印日志,用 span.ToString() 没问题;但如果你在热路径中频繁调用,那 Span 的优势就没了。考虑直接用 Utf8FormatterIBufferWriter 写输出。
  3. Span 不是线程安全的。 多个线程同时读同一个 ReadOnlySpan 是安全的(不可变语义),但任何写操作都需要同步。别想着用 Span 实现无锁并发——它只是减少了分配,不提供同步保证。

FAQ

Q: Span<T> 和 ArraySegment<T> 有什么区别?

ArraySegment 是 .NET 4.5 时代的产物,只支持数组切片,不支持 string 和 stackalloc。Span 是它的现代化替代品,API 更丰富(IndexOf、Slice、CopyTo 等),且支持更多内存源。除非维护老项目,否则一律用 Span。

Q: 什么时候不应该用 Span?

数据量很小的场景(几百字节),Premature optimization is the root of all evil。普通 string 操作的可读性更好。或者你需要长期持有数据、数据需要跨 async 边界且不想用 Memory——那就老老实实分配 string。

Q: Memory<T> 的分配开销是多少?

Memory<T> 本身是 16 字节的值类型(一个 object 引用 + offset + length),创建它不产生堆分配。真正的分配来自它引用的数据源。如果你的数据源是 ArrayPool 租来的,那唯一的开销就是池租用/归还的簿记。

实际项目落地时的几个关键决策

知道 Span 怎么用是一回事,在真实项目中推开是另一回事。以下是我踩过的坑和应对方案:

决策一:要不要全项目推广 Span?

不需要。 Span 适合处理的是字符串密集操作、二进制协议解析、数据管道等场景。普通业务逻辑(比如根据用户 ID 查数据库然后返回一个 DTO)用 Span 是过度设计。我的判断标准很简单:如果这段代码的堆分配在 profiler 里占比超过 20%,才值得上 Span。

决策二:ArrayPool 还是 stackalloc?

stackalloc 适合你知道确切大小且不大的场景(几百字节到几 KB 是安全区间)。一旦超过几 KB,要么用 ArrayPool 租用,要么换 Memory<T> 加 byte[] 缓冲。我在生产环境用的策略是:

  • < 2KB → stackalloc,快如闪电
  • 2KB ~ 64KB → ArrayPool,避免栈溢出
  • > 64KB → 直接 new byte[](大对象堆上),反正 LOH 回收频率低

决策三:性能验证的黄金法则

用 BenchmarkDotNet 而不是 Stopwatch。Stopwatch 的结果在你电脑上有效,在 CI 上可能完全漂移。BenchmarkDotNet 会做预热、多次迭代、统计分布——这是唯一可信的 .NET 性能数据来源。如果你们的 PR review 能从「看起来快」变成「这个 benchmark 证明快了 3.2 倍」,争论直接结束。

相关阅读

如果你对性能优化感兴趣,这几篇也值得一读:

总结

这次优化让我真正理解了 Span 不是”更快的 string”——它是一种完全不同的编程模型。它不是优化,是改思路:

  • 从”复制数据”变成”借用视图”
  • 从”堆分配一切”变成”栈优先、池容灾”
  • 从”依赖 GC”变成”自己掌控内存生命周期”

搞懂 Span 和 Memory,你才算真正理解了 .NET 的高性能编程范式。下次再遇到大字符串处理、二进制协议解析、网络数据包解析,先想想能不能用 Span 搞定——大概率能,而且效果会让你惊喜。

想进一步掌握 .NET 内存问题的定位手段,推荐阅读:C#/.NET 性能诊断工具链深度评测:dotnet-dump 与 dotnet-gcdump 实战

延伸阅读:Span 解决的是内存分配问题,而异步代码里的线程池饥饿是另一类性能杀手,可以看看 C# async/await 深度实战:从 UI 死锁到线程池饥饿的 5 个陷阱

免责声明:本文基于作者在生产环境中踩过的实际问题和 .NET 9 实测数据撰写,不构成任何形式的投资或技术选型建议。文中代码为演示用途,生产环境请根据具体场景调整和测试。

📤 分享这篇文章