C# Span<T>:从切片到内存安全的高性能编程
很多性能问题并不来自某个算法写错了,而是来自一些本来很自然的操作被放进了高频路径:截取字符串、复制数组的一段、为了传参创建临时缓冲区。它们在业务代码里通常完全合理;但在协议解析、日志处理、序列化和网关这类场景中,临时对象与复制次数累积起来,就会增加 GC 压力。
Span<T> 提供了另一种处理连续内存的方式:不急着创建新容器,而是先在已有数据上划出一扇“窗口”。这篇文章从最常见的数组、字符串切片开始,逐步进入 stackalloc、ref struct 生命周期、C# 13 的限制变化,以及如何设计 Span 友好的 API。
先给结论:Span 的价值主要是减少不必要的复制和临时分配。它不是让任意代码自动变快的开关;如果代码不在热点上,清晰的 string、数组和集合 API 往往仍是更好的选择。
Span<T> 到底是什么
可以把 Span<T> 看作一段连续内存上的临时视图。它不拥有元素,也不决定元素何时释放;它只描述“从哪里开始”和“能访问多长”。因此,针对 Span 的切片通常只会生成另一个视图,而不会复制其中的元素。
这个定义包含三个重要事实:
Span<T>只能表示连续数据,例如数组的一段、栈上的一段缓冲区,或非托管连续内存。- 多个 Span 可以同时指向同一块底层数据;写入一个可写 Span,其他视图和原数组都能看到变化。
- Span 很轻量,但并不意味着没有边界检查。正常索引访问越界仍会抛出
IndexOutOfRangeException。
从概念上说,Span 可以理解为“引用 + 长度”。这是帮助理解切片与边界的心智模型,不应把它当成所有 .NET 运行时都保证不变的二进制布局。
官方 API 参考见 System.Span<T>。
从数组开始使用 Span<T>
数组是最容易理解的来源。数组可以隐式转换为 Span<T>,也可以通过 AsSpan 指定起点和长度:
int[] numbers = [10, 20, 30, 40, 50];
Span<int> all = numbers;
Span<int> middle = numbers.AsSpan(1, 3);
Console.WriteLine(middle.Length); // 3
Console.WriteLine(middle[0]); // 20
middle 指向的是 numbers[1]、numbers[2]、numbers[3],不是一个新的三元素数组。最直观的验证方式是修改它:
int[] numbers = [10, 20, 30, 40, 50];
Span<int> middle = numbers.AsSpan(1, 3);
middle[0] = 200;
Console.WriteLine(numbers[1]); // 200
这也是 Span 与 array[start..end] 的关键区别。后者会创建一个新数组;前者只是创建视图。
切片为什么不会复制数据
在 Span 上可以使用范围语法,也可以使用 Slice。两种写法都只改变视图的起点和长度:
int[] numbers = [10, 20, 30, 40, 50];
Span<int> span = numbers;
Span<int> byRange = span[1..4];
Span<int> bySlice = span.Slice(1, 3);
byRange[2] = 400;
Console.WriteLine(numbers[3]); // 400
Console.WriteLine(bySlice[2]); // 400
“切片不复制”不等于“后续任何操作都不分配”。例如把字符 Span 转回字符串时,ToString() 需要构造一个独立的 string:
ReadOnlySpan<char> name = "Ada Lovelace".AsSpan()[..3];
string copiedName = name.ToString(); // 这里创建了新的字符串
所以更准确的说法是:Slice 和范围切片能避免为了得到子区间而进行的那一次复制。是否产生其他分配,要看之后调用了什么 API。
字符串为什么得到 ReadOnlySpan<char>
string 在 .NET 中不可变,因此从字符串得到的是 ReadOnlySpan<char>,而不是可写的 Span<char>:
string source = "user:alice";
ReadOnlySpan<char> text = source.AsSpan();
int separatorIndex = text.IndexOf(':');
ReadOnlySpan<char> userName = text[(separatorIndex + 1)..];
Console.WriteLine(userName.ToString()); // alice
ReadOnlySpan<char> 的好处不只是“不能写”。它还让方法可以同时接受字符串、char[]、其他 Span 和 stackalloc 创建的字符缓冲区。对于同步的只读输入,官方的 Memory 和 Span 使用准则 建议优先把参数设计为 ReadOnlySpan<T>。
实战:低分配解析订单文本
假设收到一段格式固定的订单文本:
orderId=1024;amount=99.50
先写一个易懂的版本并没有问题:
static bool TryParseOrderWithSplit(
string text,
out int orderId,
out decimal amount)
{
orderId = default;
amount = default;
string[] parts = text.Split(';');
if (parts.Length != 2)
{
return false;
}
if (!parts[0].StartsWith("orderId=", StringComparison.Ordinal) ||
!parts[1].StartsWith("amount=", StringComparison.Ordinal))
{
return false;
}
return int.TryParse(parts[0]["orderId=".Length..], out orderId) &&
decimal.TryParse(
parts[1]["amount=".Length..],
System.Globalization.NumberStyles.Number,
System.Globalization.CultureInfo.InvariantCulture,
out amount);
}
这个版本的优点是直观。代价是 Split 会创建数组和字符串片段;在一次管理后台导入中通常无需在意,但如果每秒要处理大量短消息,就值得考虑减少这些临时对象。
下面改用 ReadOnlySpan<char>。注意,这不是为了压缩代码行数,而是为了让字段始终只是原文本上的视图:
static bool TryParseOrder(
ReadOnlySpan<char> text,
out int orderId,
out decimal amount)
{
orderId = default;
amount = default;
int separatorIndex = text.IndexOf(';');
if (separatorIndex <= 0 || separatorIndex >= text.Length - 1)
{
return false;
}
ReadOnlySpan<char> orderPart = text[..separatorIndex];
ReadOnlySpan<char> amountPart = text[(separatorIndex + 1)..];
const string OrderPrefix = "orderId=";
const string AmountPrefix = "amount=";
if (!orderPart.StartsWith(OrderPrefix, StringComparison.Ordinal) ||
!amountPart.StartsWith(AmountPrefix, StringComparison.Ordinal))
{
return false;
}
ReadOnlySpan<char> orderValue = orderPart[OrderPrefix.Length..];
ReadOnlySpan<char> amountValue = amountPart[AmountPrefix.Length..];
return int.TryParse(orderValue, out orderId) &&
decimal.TryParse(
amountValue,
System.Globalization.NumberStyles.Number,
System.Globalization.CultureInfo.InvariantCulture,
out amount);
}
这里 IndexOf、范围切片、StartsWith 和数值 TryParse 都直接操作字符视图。方法还明确处理了缺少分号、空字段、错误键名和无效数字。调用方可以传入字符串:
if (TryParseOrder("orderId=1024;amount=99.50", out int orderId, out decimal amount))
{
Console.WriteLine($"订单 {orderId},金额 {amount}");
}
同一个签名也可以接受 char[] 的一段或栈上的缓冲区。它避免的是字段切分阶段的复制;如果业务随后要把字段长期保存为字符串,仍应在那个真正需要所有权的位置调用 ToString()。
stackalloc:让小型临时缓冲区留在栈上
数组由托管堆管理。对于作用域很短、大小可控的小缓冲区,可以用 stackalloc 在栈上申请空间,再用 Span 安全地访问它:
Span<char> buffer = stackalloc char[8];
buffer[0] = 'S';
buffer[1] = 'p';
buffer[2] = 'a';
buffer[3] = 'n';
ReadOnlySpan<char> text = buffer[..4];
Console.WriteLine(text.ToString()); // Span
stackalloc 并不要求你回到指针时代。把它赋给 Span<T> 后,正常的边界检查仍然存在;越界访问会抛出异常,而不是静默破坏内存。Microsoft 的 unsafe 代码最佳实践 也建议优先将栈分配的内存交给 Span 使用。
但它有明确边界:
- 只用于大小小、生命周期短且上限明确的临时缓冲区。
- 不要用不受控的外部输入直接决定
stackalloc的大小。 - 需要长期保存、跨异步调用或可能很大的数据时,使用数组、池化数组或
Memory<T>更合适。
Span、ReadOnlySpan、Memory 应该怎么选
这四个类型常被放在一起讨论,真正的选择依据是:是否只读、是否同步使用、是否需要保存。
| 类型 | 可写 | 能否长期保存或跨异步边界 | 常见用途 |
|---|---|---|---|
Span<T> | 是 | 否 | 同步方法里的临时可写缓冲区 |
ReadOnlySpan<T> | 否 | 否 | 同步方法的只读输入、字符串解析 |
Memory<T> | 是 | 是 | 字段、异步 I/O、延迟处理的缓冲区 |
ReadOnlyMemory<T> | 否 | 是 | 异步或长期保存的只读数据 |
一个实用规则是:同步 API 的参数能用 Span 就先用 Span;一旦数据要被存起来或跨过 await,再升级为 Memory。 Memory<T> 可以在需要时通过 .Span 提供同步视图,而 Span<T> 不能反向变成可长期保存的 Memory。
例如,下面这个同步方法的调用方既可以传字符串,也可以传字符数组或栈缓冲区:
static int CountSeparators(ReadOnlySpan<char> text, char separator)
{
int count = 0;
foreach (char character in text)
{
if (character == separator)
{
count++;
}
}
return count;
}
而异步读取后需要保留缓冲区时,更适合让 API 接收或返回 Memory<byte>、ReadOnlyMemory<byte>,在同步处理片段时再使用 .Span。
深入 ref struct 与生命周期
Span<T> 和 ReadOnlySpan<T> 是 ref struct。这不是一个只影响语法的标签,而是编译器对生命周期的一套保护:Span 可能指向数组、非托管内存,甚至栈上的数据;它不能逃逸到比这块内存活得更久的位置。
因此,Span 有一些看似严格、实则很有价值的限制:
- 不能装箱为
object;把它保存到接口类型变量同样会涉及装箱,因此不能这样使用。 - 不能作为普通
class的实例字段保存。 - 不能被 lambda、匿名方法或闭包捕获。
- 不能随意传给可能将其保存起来的 API。
例如下面的代码不能通过编译,因为类字段可能比方法中的栈缓冲区活得更久:
// 编译错误:普通类字段不能是 Span<int>
public sealed class InvalidCache
{
private Span<int> _buffer;
}
这些约束避免了“方法返回后仍引用已失效的栈内存”之类的悬空引用。关于 ref struct、ref 安全上下文和更多限制,可参考 C# 的 ref struct 文档。
C# 13 对 async 和迭代器的放宽
过去常能看到一句简化说法:“Span 不能出现在 async 方法中。”这在现在已经不够准确。
从 C# 13 开始,async 方法和迭代器可以声明 ref 局部变量或 ref struct 局部变量,包括 Span;但它们不能跨越 await 或 yield return 边界被访问。原因没有变:一旦方法暂停,局部状态可能被提升并在之后恢复,而 Span 不能被放进那样的长期状态中。
下面的模式是安全的,因为 Span 在 await 前就已经用完:
static async Task<int> CountBeforeAwaitAsync(string input)
{
ReadOnlySpan<char> span = input.AsSpan();
int length = span.Length;
await Task.Delay(1);
return length;
}
但不要在 await 之后继续读取同一个 Span。需要跨异步边界保存数据时,应改用 Memory<T>、ReadOnlyMemory<T> 或复制出真正拥有数据的数组/字符串。C# 13 的具体变化见 What’s new in C# 13。
同一版本还引入了 where T : allows ref struct 反约束,让泛型算法在显式声明并遵守 ref 安全规则的前提下接纳 ref struct 类型。它解决的是库设计的扩展性问题,不是日常业务代码必须使用的语法。
Span 为什么可能更快
Span 常见的性能收益有两个来源:
- 避免复制子区间,例如不用
Substring、数组切片或拆分后的小数组来表示字段。 - 避免短命对象,例如高频解析中反复创建的字符串和集合。
这并不意味着所有 Span 代码都更快。一次简单的业务校验可能主要耗在数据库或网络 I/O 上;此时把字符串代码改成 Span 不会改变整体延迟,反而可能降低可读性。
此外,索引访问仍受边界检查保护。JIT 在能够证明循环索引安全时,可能消除部分重复检查;但这是一种实现优化,不应该成为业务正确性的前提,也不应假定每段循环都会发生。真正需要优化时,先用可靠的测量确认分配和 CPU 时间的热点,再做针对性改动。
如何设计 Span 友好的 API
设计 API 时,先问“调用方是否需要把数据保存下来”,再问“是否需要修改它”。
- 同步且只读:优先
ReadOnlySpan<T>。调用方可以传数组、字符串、Memory 或栈缓冲区。 - 同步且需要修改调用方缓冲区:使用
Span<T>。 - 需要保存、排队、跨
await或延迟处理:使用Memory<T>/ReadOnlyMemory<T>。 - 方法若只需要遍历数据,不要为了返回一个 Span 而让调用方承担生命周期约束;返回计算结果通常更简单。
下面是一个同步格式化 API 的例子。调用方提供目标缓冲区,方法报告实际写入长度,不自己分配临时数组:
static bool TryWritePrefix(
ReadOnlySpan<char> value,
Span<char> destination,
out int written)
{
const string Prefix = "value=";
int requiredLength = Prefix.Length + value.Length;
if (destination.Length < requiredLength)
{
written = default;
return false;
}
Prefix.AsSpan().CopyTo(destination);
value.CopyTo(destination[Prefix.Length..]);
written = requiredLength;
return true;
}
这类 Try... 形式适合确实需要控制分配的格式化、编码和协议写入场景。普通业务代码不必机械地把所有方法都改成 Span 版本。
常见误区
把零复制理解成完全零分配
Span 切片可以零复制,但后续的 ToString()、LINQ、装箱、日志格式化或创建返回对象仍可能分配。应具体观察热点上的完整调用链,而不是只看某一行是否使用 Span。
为了性能无条件使用 stackalloc
栈空间不是无限资源。固定且很小的临时缓冲区适合 stackalloc;大小受输入控制、递归很深或生命周期不明确的场景不适合。
把 Span 当成可缓存的数据结构
Span 是临时视图,不是所有权模型。需要缓存的是数组、字符串、Memory<T>,或者一个明确拥有底层数据的对象。
认为 Span 没有边界检查
正常的 Span 索引访问有边界检查。它提供的是更少的复制与更好的内存抽象,不是要求开发者牺牲安全来换速度。
使用 Span 前的检查清单
准备引入 Span 前,可以依次问自己:
- 数据是不是连续的?如果是链表、分段管道或离散对象,Span 可能不是直接的抽象。
- 方法只读还是需要写入?只读时优先
ReadOnlySpan<T>。 - 数据会不会跨越
await、排队或被对象字段保存?会的话使用 Memory 或拥有数据的类型。 - 当前问题到底是复制/分配热点,还是 I/O、算法和数据库问题?先测量。
- 用 Span 后是否仍然比原实现更容易维护?如果不是热点,可读性通常更重要。
总结
Span<T> 的核心不是一套“高性能语法”,而是一种更贴近数据生命周期的 API 设计方式:临时同步地处理连续内存时,用轻量视图避免无意义的复制;需要跨作用域、跨异步边界或长期保存时,把所有权交给数组、字符串或 Memory。
从数组切片和字符串解析开始练习,先建立“视图不拥有数据”的意识,再理解 ref struct 为什么限制逃逸。这样使用 Span 时,性能优化与内存安全就不再是两件互相冲突的事。