很多性能问题并不来自某个算法写错了,而是来自一些本来很自然的操作被放进了高频路径:截取字符串、复制数组的一段、为了传参创建临时缓冲区。它们在业务代码里通常完全合理;但在协议解析、日志处理、序列化和网关这类场景中,临时对象与复制次数累积起来,就会增加 GC 压力。

Span<T> 提供了另一种处理连续内存的方式:不急着创建新容器,而是先在已有数据上划出一扇“窗口”。这篇文章从最常见的数组、字符串切片开始,逐步进入 stackallocref 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;但它们不能跨越 awaityield 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 常见的性能收益有两个来源:

  1. 避免复制子区间,例如不用 Substring、数组切片或拆分后的小数组来表示字段。
  2. 避免短命对象,例如高频解析中反复创建的字符串和集合。

这并不意味着所有 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 前,可以依次问自己:

  1. 数据是不是连续的?如果是链表、分段管道或离散对象,Span 可能不是直接的抽象。
  2. 方法只读还是需要写入?只读时优先 ReadOnlySpan<T>
  3. 数据会不会跨越 await、排队或被对象字段保存?会的话使用 Memory 或拥有数据的类型。
  4. 当前问题到底是复制/分配热点,还是 I/O、算法和数据库问题?先测量。
  5. 用 Span 后是否仍然比原实现更容易维护?如果不是热点,可读性通常更重要。

总结

Span<T> 的核心不是一套“高性能语法”,而是一种更贴近数据生命周期的 API 设计方式:临时同步地处理连续内存时,用轻量视图避免无意义的复制;需要跨作用域、跨异步边界或长期保存时,把所有权交给数组、字符串或 Memory。

从数组切片和字符串解析开始练习,先建立“视图不拥有数据”的意识,再理解 ref struct 为什么限制逃逸。这样使用 Span 时,性能优化与内存安全就不再是两件互相冲突的事。