前言
写业务代码时,大部分性能问题出在数据库、网络和序列化上,字符串和数组的分配通常不值得优化。但在解析协议、处理文件、写通用库和热路径接口时,每一次多余的分配都会变成 GC 压力。Span<T> 和 Memory<T> 是 .NET 里处理这类问题的标准工具。
本文只讲语言和运行时层面的通用用法,不依赖任何具体项目框架。示例基于 .NET 8。
一、Span<T> 是什么
Span<T> 是一段连续内存的「视图」:它自己不持有内存,只描述从哪里开始、有多长。可以从数组、字符串、栈内存创建:
int[] numbers = [1, 2, 3, 4, 5];
Span<int> slice = numbers.AsSpan(1, 3); // 2,3,4
slice[0] = 20; // 修改会影响原数组
Console.WriteLine(numbers[1]); // 20
切片不复制数据,创建 Span 的成本接近于零。这是它和 Substring、数组拷贝最大的区别。
二、ref struct 与使用限制
Span<T> 是 ref struct,只能存在于栈上:不能作为类的字段、不能放进 List、不能跨 async 方法保存。这些限制换来的是运行时不需要额外检查就能保证内存安全。
// 编译错误:Span 不能保存到字段
// class Parser {{ private Span<byte> _buffer; }}
// 编译错误:不能跨 await 使用
// async Task Parse(Span<byte> data) {{ await Task.Yield(); }}
需要在 async 方法之间传递缓冲区时,就用 Memory<T>。
三、Memory<T> 与 async 场景
Memory<T> 是 Span<T> 的「可以放在堆上」的版本,代价是每次使用时要先切片成 Span:
async Task ReadAsync(Memory<byte> buffer)
{{
int read = await stream.ReadAsync(buffer);
Span<byte> span = buffer.Span[..read];
Process(span);
}}
四、最常用的场景:字符串解析
不用 Substring 逐段切字符串,而是用 ReadOnlySpan 一次解析:
string line = "192.168.1.10:8080";
ReadOnlySpan<char> span = line.AsSpan();
int sep = span.IndexOf(':');
ReadOnlySpan<char> ip = span[..sep];
int port = int.Parse(span[(sep + 1)..]);
Console.WriteLine($"{{ip.ToString()}}:{{port}}");
整个过程没有中间字符串分配。解析量大的日志、协议报文场景下差距明显。
五、stackalloc 与缓冲区复用
短小的临时缓冲区可以直接分配在栈上,用完随栈帧释放,不给 GC 添活:
Span<char> buffer = stackalloc char[64];
value.TryFormat(buffer, out int written, "F2");
string result = buffer[..written].ToString();
byte[] rented = ArrayPool<byte>.Shared.Rent(4096);
try
{{
// 使用 rented[0..actualLength]
}}
finally
{{
ArrayPool<byte>.Shared.Return(rented);
}}
六、Span 与 Memory 的选择
| 场景 | 选择 | 原因 |
|---|---|---|
| 同步方法内切片、解析 | Span<T> | 零分配、访问最快 |
| 跨 await 传递缓冲区 | Memory<T> | 可以存到字段或局部变量跨挂起点 |
| 公开 API 只读参数 | ReadOnlySpan<T> | 表达只读语义,数组字符串都能隐式转换 |
| 长期持有或缓存 | 数组本身 / IMemoryOwner<T> | Span 不能上堆,池化内存要显式管理 |
七、避开常见的坑
- Span 的内容可能指向被池化的数组,Return 之后继续持有引用是典型的悬空 bug。
- ReadOnlySpan 只约束「不能通过这个视图修改」,底层内存可能同时在被别处修改,多线程下要自己保证同步。
- 字符串插值里直接拼 Span 会隐式 ToString,热路径上留意这些隐藏分配。
- 性能优化先测量再动手:BenchmarkDotNet 加 MemoryDiagnoser 观察分配数,优化前后对比。
八、生产使用注意事项
- 先用基准测试确认热点,不要凭感觉把所有字符串操作改成 Span,可读性也是成本。
- 公开库 API 用 ReadOnlySpan 做参数时要在文档说明生命周期,避免调用方误存。
- 解析外部输入时长度校验先行,切片越界会直接抛异常。
- .NET 8 的 SearchValues 配合 IndexOfAny 做多字符查找,比手写循环更快。
总结
Span<T> 和 Memory<T> 的价值是把「切片」从复制数据变成描述范围,把临时缓冲区从堆分配变成栈分配或池化复用。同步热路径用 Span,异步边界用 Memory,公开参数用 ReadOnly 变体,长期持有先想清楚所有权。
推荐的落地顺序是:先在解析和序列化这两类热点里试点,用基准测试确认收益,再把模式沉淀成团队里的通用工具方法。性能优化始终以测量为准,Span 是工具不是目的。










暂无评论内容