【.NET】Span和Memory:高性能代码的基础知识

前言

写业务代码时,大部分性能问题出在数据库、网络和序列化上,字符串和数组的分配通常不值得优化。但在解析协议、处理文件、写通用库和热路径接口时,每一次多余的分配都会变成 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>。

经验法则:同步代码用 Span,跨 await 的代码用 Memory,公开 API 参数优先用 ReadOnlySpan 或 ReadOnlyMemory。

三、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();
栈空间有限,stackalloc 一般不超过几百字节。更大的临时缓冲区用 ArrayPool<T> 租借,用完必须 Return,否则池会被耗尽。
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 观察分配数,优化前后对比。
注意:池化数组 Return 之后不要再读写其中的内容;ReadOnlySpan 不代表底层内存不可变,并发修改需要自行同步。

八、生产使用注意事项

  • 先用基准测试确认热点,不要凭感觉把所有字符串操作改成 Span,可读性也是成本。
  • 公开库 API 用 ReadOnlySpan 做参数时要在文档说明生命周期,避免调用方误存。
  • 解析外部输入时长度校验先行,切片越界会直接抛异常。
  • .NET 8 的 SearchValues 配合 IndexOfAny 做多字符查找,比手写循环更快。

总结

Span<T> 和 Memory<T> 的价值是把「切片」从复制数据变成描述范围,把临时缓冲区从堆分配变成栈分配或池化复用。同步热路径用 Span,异步边界用 Memory,公开参数用 ReadOnly 变体,长期持有先想清楚所有权。

推荐的落地顺序是:先在解析和序列化这两类热点里试点,用基准测试确认收益,再把模式沉淀成团队里的通用工具方法。性能优化始终以测量为准,Span 是工具不是目的。

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容