📦 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.2 | Span<T> / ReadOnlySpan<T> 首次引入(需 System.Memory NuGet) |
| .NET Core 2.1 | BCL 全面 Span 化——AsSpan()、TryFormat 等 API |
| C# 8 | 索引/范围语法(^ / ..)原生支持 Span |
| C# 11 | UTF-8 String Literals("text"u8)、List Patterns 支持 Span、ref struct 泛型参数 |
| .NET 6+ | Span 全覆盖——几乎所有 IO API 都有 Span 重载 |
| C# 12 | ref readonly 参数、内联数组Inline Arrays |
| C# 13 | ref 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
}
相关速查 & 课程