一个真实的翻车现场
上个月我们组一个数据导入服务突然 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 方法的参数)。编译器会在编译期强制检查这些约束。stackalloc:fieldRanges数组分配在栈上,不是堆。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 |

堆分配降低了 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 个坑
- Span 比原始数据活得短就会炸。 Span 只是”借用”别人的内存,数据源被释放了 Span 就是悬垂指针。如果你用
stackalloc分配的 Span 被返回到方法外部——恭喜你,未定义行为。 ToString()会强行分配。span.ToString()会在堆上新建一个 string。如果你只是想打印日志,用span.ToString()没问题;但如果你在热路径中频繁调用,那 Span 的优势就没了。考虑直接用Utf8Formatter或IBufferWriter写输出。- 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 倍」,争论直接结束。
相关阅读
如果你对性能优化感兴趣,这几篇也值得一读:
- C# LINQ 深度性能优化:从 480ms 到 18ms——一次生产慢查询的完整排查复盘 — 同样是生产环境翻车驱动的优化,从另一个角度看 C# 高性能编程
- Python 性能剖析三件套:py-spy、Scalene、memray 实战对比 — 跨语言通用的 profiling 方法论,C# 场景下用 dotTrace 或 BenchmarkDotNet 异曲同工
- 生产环境死锁排查完全指南:从 Python 线程死锁到 MySQL 锁等待 — 又是凌晨故障复盘,排查思路比具体语言重要
总结
这次优化让我真正理解了 Span 不是”更快的 string”——它是一种完全不同的编程模型。它不是优化,是改思路:
- 从”复制数据”变成”借用视图”
- 从”堆分配一切”变成”栈优先、池容灾”
- 从”依赖 GC”变成”自己掌控内存生命周期”
搞懂 Span 和 Memory,你才算真正理解了 .NET 的高性能编程范式。下次再遇到大字符串处理、二进制协议解析、网络数据包解析,先想想能不能用 Span 搞定——大概率能,而且效果会让你惊喜。
想进一步掌握 .NET 内存问题的定位手段,推荐阅读:C#/.NET 性能诊断工具链深度评测:dotnet-dump 与 dotnet-gcdump 实战。
延伸阅读:Span 解决的是内存分配问题,而异步代码里的线程池饥饿是另一类性能杀手,可以看看 C# async/await 深度实战:从 UI 死锁到线程池饥饿的 5 个陷阱。
免责声明:本文基于作者在生产环境中踩过的实际问题和 .NET 9 实测数据撰写,不构成任何形式的投资或技术选型建议。文中代码为演示用途,生产环境请根据具体场景调整和测试。