📦 Span / ReadOnlySpan 速查

C# 7.2 引入 · 栈上类型安全内存视图 · 零分配切片 · 现代 .NET 高性能基石

一句话定义

Span<T> = 指向连续内存的"窗口"——包含一个 ref 指针 + 一个长度。它不拥有内存,只是查看。切片/读/写全部零堆分配。
原始数组(堆上): int[] arr = [10, 20, 30, 40, 50] 0 1 2 3 4 Span<int> window = arr.AsSpan().Slice(1, 3); ┌──────────────────────────────┐ │ ref ────→ arr[1](地址) │ │ length = 3 │ │ 内容:[20, 30, 40] │ └──────────────────────────────┘ window[0] = 99 → arr[1] 也 = 99 ← 同一块内存!

Span vs ReadOnlySpan

Span<T>ReadOnlySpan<T>
从数组arr.AsSpan()arr.AsSpan()(隐式转换)
从 string❌(string 不可变)str.AsSpan()
从 stackalloc✅ 直接赋值✅ 隐式转换
从非托管内存new Span<T>(ptr, len)new ReadOnlySpan<T>(ptr, len)
典型场景缓冲区读写子串、UTF-8 字面量、数据解析

五种内存来源

// ① 托管数组(堆上)——最常见
int[] arr = [1, 2, 3, 4, 5];
Span<int> s1 = arr.AsSpan();             // 整个数组
Span<int> s2 = arr.AsSpan(2, 3);         // 索引 2 起 3 个

// ② 栈上分配——stackalloc(无 GC)
Span<byte> buf = stackalloc byte[256];   // 栈上 256 字节,方法结束自动回收

// ③ 字符串——ReadOnlySpan<char>
ReadOnlySpan<char> sub = "Hello World".AsSpan(6, 5);  // "World",零分配!

// ④ 非托管内存(native heap)
unsafe {
    void* ptr = NativeMemory.Alloc(1024);
    Span<byte> s = new Span<byte>(ptr, 1024);
}

// ⑤ UTF-8 字面量(C# 11)——编译期编码
ReadOnlySpan<byte> utf8 = "hello"u8;  // PE 数据段,零运行时开销

常用 API

方法作用分配?
.Slice(start, length)切片——取子窗口❌ 零分配
[start..end]范围切片(C# 8 索引/范围语法)❌ 零分配
.CopyTo(destination)复制到另一个 Span❌ 零分配
.TryCopyTo(destination)尝试复制,目标不够长则返回 false❌ 零分配
.ToArray()复制到新数组✅ 新数组!
.ToString()Span→string(仅 char 类型)✅ 新字符串!
.IndexOf(value)查找元素索引❌ 零分配
.IndexOfAny(values)查找任意匹配项(C# 12+ SearchValues 优化)❌ 零分配
.StartsWith() / .EndsWith()前缀/后缀匹配❌ 零分配
.SequenceEqual(other)逐元素比较❌ 零分配
.Trim() / .TrimStart()去首尾空白(仅 char/byte)❌ 零分配
.Fill(value)填充统一值❌ 零分配
.Clear()清零❌ 零分配
// 范围语法(C# 8)——Span 原生支持
Span<int> s = arr.AsSpan();
var first3 = s[..3];      // 前 3 个
var last3 = s[^3..];     // 后 3 个
var middle = s[2..^1];   // 索引 2 到倒数第 2(不含)

最关键的约束:ref struct

Span<T> 被声明为 ref struct——它只能在栈上存在。这是它零开销的根本原因,也是它最大的使用限制。
✅ 可以❌ 不可以
局部变量、方法参数、返回值作为 class / struct 的字段
同步方法内使用跨越 await(async 方法中 await 前后不能有 Span)
stackalloc 赋给 Span装箱(object obj = span
传给泛型方法(编译器特判)实现接口(IEnumerable<T> 等)
ref struct 泛型参数(C# 11+)Lambda 闭包捕获 / LINQ
数组池租用(ArrayPool<T> 配合 Span)作为 dynamic 类型
// ❌ 典型错误
class MyBuffer {
    Span<byte> _data;  // CS8345: 字段不能是 ref struct
}

async Task<void> ProcessAsync() {
    Span<byte> buf = stackalloc byte[64];
    await Task.Delay(1);  // CS4012: ref struct 不能跨越 await
    buf[0] = 0xFF;
}

// ✅ 需要存储/跨 async → 用 Memory<T>
class MyBuffer {
    Memory<byte> _data;  // ✅ Memory 可以放堆上
}

async Task<void> ProcessAsync() {
    Memory<byte> mem = new byte[64];
    await Task.Delay(1);
    var span = mem.Span;  // 在 await 之后取 Span 使用
    span[0] = 0xFF;
}
ref struct 不能实现接口,但编译器有特殊处理。foreach 可以遍历 Span(编译器生成索引器循环而不是 IEnumerator),C# 13 还允许 ref struct 实现接口——但在此之前 Span 完全是编译器特殊照顾。

Span 三兄弟:Span · Memory · ReadOnlySequence

类型可存储可跨 async连续内存典型用途
Span<T>❌ 仅栈✅ 单段同步热路径:切片、解析、写入缓冲
Memory<T>✅ 可做字段✅ 单段异步 IO、Pipeline、配置属性
ReadOnlySequence<T>❌ 多段Pipe 读取(数据可能跨多个 buffer)
经验法则:同步用 Span,需要存储或跨 await 用 Memory,遇到 Pipe 的多段数据用 ReadOnlySequence。Memory 用前加 .Span 即可取回 Span。

String 操作:分配 vs 零分配对照

// 传统方式——每次都分配新字符串
var s = "Hello World";
var sub = s.Substring(6, 5);         // "World"——堆分配
var trimmed = s.Trim();               // 新字符串
var replaced = s.Replace('l', 'x');  // 新字符串

// Span 方式——零分配
ReadOnlySpan<char> span = "Hello World".AsSpan();
var sub2 = span.Slice(6, 5);          // 只是移动指针,零分配
var trimmed2 = span.Trim();           // 零分配

// ⚠️ 什么时候需要 ToString()?
// 传给接受 string 的 API 时——如 Console.WriteLine、Path.Combine、旧版 ORM

常见模式

① 解析分隔字符串(CSV、URL 路径等)

ReadOnlySpan<char> line = "Alice,30,Engineer";
int comma1 = line.IndexOf(',');
int comma2 = line.Slice(comma1 + 1).IndexOf(',') + comma1 + 1;

var name = line[..comma1];                    // "Alice"
var age = line[(comma1 + 1)..comma2];         // "30"
var role = line[(comma2 + 1)..];              // "Engineer"
// 整个过程零分配!

② 栈缓冲区替代小数组

// 旧:哪怕 32 字节也走堆
byte[] temp = new byte[32];

// 新:256 字节以下考虑栈
Span<byte> temp = stackalloc byte[32];
// 方法结束自动回收,GC 永远不知道它存在过

③ List Patterns 匹配 Span(C# 11)

ReadOnlySpan<int> nums = [1, 2, 3, 4, 5];
if (nums is [1, .., 5]) { }              // ✅ Span 原生支持 List Patterns
if (nums is [>= 0, >= 0, .., >= 0]) { } // 全部 ≥ 0

④ 从 ArrayPool 租用(超大临时缓冲)

byte[]? rented = null;
try {
    rented = ArrayPool<byte>.Shared.Rent(4096);
    Span<byte> buf = rented.AsSpan(0, 4096);  // Span 包装租用的数组
    // ... 处理 ...
} finally {
    if (rented != null) ArrayPool<byte>.Shared.Return(rented);
}

什么时候用 Span?

✅ 用 Span❌ 不必用 Span
热路径上的字符串/数组切片冷路径——代码可读性优先
解析:JSON、CSV、URL、协议头只需要一次性的 Substring
文件 IO 缓冲(.NET 6+ File.ReadAllBytes 也提供 Span 重载)老式 API 只接受 string——硬转反而多分配
ASP.NET Core 中间件处理请求体业务逻辑层——用 POCO 更清晰
System.Text.Json 高性能序列化Newtonsoft.Json 迁移不完全的场景
编码转换:Base64、Hex、UTF-8跨度大、逻辑复杂的字符串拼接

性能本质

Span 操作

ReadOnlySpan<char> s = str.AsSpan();
s = s.Trim();
s = s[1..^1];
// IL:几个 mov/lea/add 指令
// 没有 call new
// 没有 GC 跟踪

等价的 string 操作

var s = str.Trim();
s = s.Substring(1, s.Length - 2);
// IL:call Substring
//     call FastAllocateString
//     每次分配新对象
Span 的 Slice = 指针偏移 + 长度减法(几个 CPU 指令)。string 的 Substring = 分配新堆对象 + 内存复制(GC 分配路径)。在每秒百万次调用的热路径上,差异从微秒变毫秒。
Span 不消除边界检查。每次索引访问 s[i],JIT 仍然会检查 i < length——但 JIT 对 Span 的边界检查消除Bounds Check Elimination做得很好,循环中通常会被优化掉。

版本演进

版本Span 相关
C# 7.2Span<T> / ReadOnlySpan<T> 首次引入(需 System.Memory NuGet)
.NET Core 2.1BCL 全面 Span 化——AsSpan()TryFormat 等 API
C# 8索引/范围语法(^ / ..)原生支持 Span
C# 11UTF-8 String Literals("text"u8)、List Patterns 支持 Span、ref struct 泛型参数
.NET 6+Span 全覆盖——几乎所有 IO API 都有 Span 重载
C# 12ref readonly 参数、内联数组Inline Arrays
C# 13ref struct 可实现接口(预览)

MemoryExtensions —— Span 的 LINQ

这些扩展方法都在 System.MemoryExtensions 类中,专门为 Span 设计——全部零分配:

类别方法
搜索IndexOf · LastIndexOf · IndexOfAny · Contains · IndexOfAnyExcept
比较StartsWith · EndsWith · SequenceEqual · SequenceCompareTo · CommonPrefixLength
写入Fill · Reverse · Replace · CopyTo
修剪Trim · TrimStart · TrimEnd(仅 char/byte 类型)
分割Split(返回 SpanSplitEnumerator<T>——零分配 foreach)
编码ToLower · ToUpper · AsciiToUpper · AsciiToLower
// Span.Split ——零分配分割
ReadOnlySpan<char> csv = "a,b,c,d";
foreach (var segment in csv.Split(',')) {
    // segment 是 Range ——零分配!
    Console.WriteLine(csv[segment].ToString());  // 需要时才调用 ToString
}

相关速查 & 课程