在C#高性能编程中,Span<T>与Memory<T>是取代数组拷贝和子串分配的核心类型。它们提供连续内存的抽象视图,让开发者在不产生新对象的前提下操作一段数据。对于刚接触这块的工程师来说,最关键的是弄清两者适用边界:Span<T>只能存在于栈或局部变量,而Memory<T>可存入字段、参与异步。下面用具体代码和原理拆解如何落地。

Span<T>的底层原理与栈上约束
Span<T>本质上是一个ref struct,它在编译后仅包含两个字段:一个指向首元素的托管指针,以及长度。由于是ref struct,它无法装箱、无法作为类的字段、无法在异步方法里跨越await使用。这种设计让它完全分配在栈上,不会给垃圾回收器增添任何负担。当我们需要从数组、字符串或栈内存中切出一段进行处理时,Span<T>只是记录偏移和长度,而不是复制数据。
例如解析一段逗号分隔的数字字符串,传统做法用string.Split会在堆上生成大量子串。改用Span<T>后,可使用ReadOnlySpan<char>配合IndexOf逐段扫描,全程零分配。下面的代码演示了如何从输入中切出第一段并转为整数:
using System;
class Parser
{
static int ParseFirst(string input)
{
ReadOnlySpan<char> span = input.AsSpan();
int comma = span.IndexOf(',');
ReadOnlySpan<char> first = comma >= 0 ? span.Slice(0, comma) : span;
return int.Parse(first);
}
}
上述代码里AsSpan把字符串转成只读视图,Slice返回的仍是原数据的视图。在高频调用如日志处理、协议解析时,这种写法能把每秒分配字节数降到接近零。但要注意,Span<T>不能缓存,方法返回后其指向的栈帧失效,因此绝不能将它存起来供后续使用。
Memory<T>在堆与异步场景的延伸
当业务逻辑需要把缓冲区块传给异步方法,或存入对象字段等待后续消费时,Span<T>的无能就显现出来。Memory<T>是非ref struct的包装,它可以位于堆上,也能作为普通字段。它内部持有类似Span的逻辑,但允许在异步边界传递。通过Memory<T>.Span属性,能在真正执行同步处理时拿到Span去做零拷贝操作。
在网络服务里常看到从Socket读取字节后做分包。下面示例用Memory<byte>接收数据,再在本地方法中用Span处理,避免拷贝到新数组:
using System;
using System.Threading.Tasks;
class Receiver
{
private Memory<byte> _buffer = new byte[1024];
public async Task<int> ReadAsync(System.IO.Stream stream)
{
int read = await stream.ReadAsync(_buffer);
Process(_buffer.Span.Slice(0, read));
return read;
}
private void Process(ReadOnlySpan<byte> data)
{
int start = data.IndexOf((byte)'n');
// 按行边界切分处理
}
}
这里的_buffer是字段级Memory<byte>,跨await安全。进入Process后通过Span属性获得栈上视图做查找,兼顾了生命周期与性能。若使用旧的byte[]加偏移参数,不仅易错,还难以表达所有权;Memory<T>让缓冲管理更清晰。对于需要组合多个缓冲区的场景,还可配合ReadOnlySequence<T>处理管道分片,不必拼接成单一大数组。
替换旧代码时的常见误区与收益对比
初学者容易把Span<T>当成普通切片随意返回,结果编译器报ref struct限制错误。另一个误区是在热路径里混用string.Substring和Span,以为局部用了Span就整体零分配,实际上Substring仍分配了字符串。正确方式是入口处转Span,全程不再回退到字符串方法。还有人试图把Span放进List,这直接违反约束,应改用池化数组或Memory列表。
从性能数据看,在一次解析十万条短消息的基准中,传统Substring加int.Parse产生约四十MB临时字符串,而Span方案分配不足一MB,吞吐提升三倍以上。下面的对照表列出关键差异:
| 方案 | 堆分配 | 适用边界 | 异步支持 |
|---|---|---|---|
| ArraySegment<T> | 依赖原数组 | 堆/字段 | 支持 |
| Span<T> | 无 | 栈/局部 | 不支持 |
| Memory<T> | 包装轻量 | 堆/字段 | 支持 |
落地建议是先审计项目中高频调用的字符串与字节处理,把内部循环改为Span,对外接口用Memory承接。配合ArrayPool<T>共享缓冲,可进一步压低大型系统的GC频率。掌握这两类类型后,很多原本靠不安全代码或繁琐偏移量维护的逻辑,都能写得既安全又飞快。