C#.NET Span 深入解析:零拷贝内存切片与高性能实战
在 你几乎一定会遇到 它之所以重要,不是因为它“新”,而是因为它解决的是一个非常实际的问题: 过去很多代码为了拿到一段子数据,会写出这种逻辑: 这些写法的问题不是“能不能用”,而是: 可以先用一句最直白的话理解: 它自己通常不拥有数据,而只是“指向”一段已有的连续内存。 所以你可以把它理解成: 这个窗口可以指向: 对应的只读版本是: 它最常见的应用,就是: 因为传统做法在很多高频场景下开销并不小。 例如字符串截取: 而 语义则是: 这就是所谓的“零拷贝切片”。 也就是说,它更像: 而不是: 这也是为什么: 例如: 这里不是改了 这会带来一系列很重要的限制,而这些限制并不是“设计缺陷”,而是为了安全。 你可以把这个设计理解成: 这也是为什么 这些限制的根源,基本都可以追溯到一句话: 这里: 字符串本身不可变,所以对应的是: 这是 这意味着: 这在小型临时缓冲区场景里非常高效。 这个选择其实很简单。 当你需要: 例如: 当你只需要: 例如: 在 API 设计上,一个很务实的建议是: 它的重点在于: 只有你显式调用复制相关操作时,才真的会发生数据复制。 这类 API 在字符串解析里非常常见。 因为字符串处理是最容易不小心产生临时对象的地方。 例如以前很多代码会这样写: 问题在于: 而用 例如: 这里的 如果后面再配合: 就能把很多中间对象都省掉。 很多人学 例如: 这表示: 这非常适合: 但这里也有一个很重要的务实建议: 这是最容易被问到的问题之一。 例如下面的代码通常就不行: 根本原因是: 所以这类跨异步边界的场景,通常应该考虑: 而不是 这是理解 可以先记这张简化表: 一个简单判断规则: 例如: 这里可以存 这是个非常实用的问题。 适合: 它也是“数组的一段视图”,比 但它的问题是: 适合: 所以今天的新代码里,如果场景允许, 不是说 如果只是偶尔处理一两次字符串,直接用普通 API 完全没问题。 但如果是这些场景: 那 假设有一行简单数据: 我们想拿到方法、路径、协议。 这个例子想表达的重点不是协议解析本身,而是: 这些限制几乎都是 这前面已经解释过,是生命周期安全问题。 你需要先分清它优化的是什么: 如果你的瓶颈根本不在这些地方,那改成 所以更务实的结论是: 如果你准备在项目里真正用 你可以这样理解它: 在今天的 那 简介
.NET 里,只要你开始关注性能,尤其是这些场景:Span<T>。如何在不额外分配内存、不额外复制数据的前提下,高效地操作一段连续内存?
string part = text.Substring(0, 5);
byte[] header = bytes.Skip(0).Take(8).ToArray();GC 压力;Span<T> 的核心价值,就是把“复制一份再处理”,变成“直接在原始内存上切一片来处理”。Span 到底是什么?
Span<T> 是对一段连续内存的可写视图。stackalloc 分配的栈内存;ReadOnlySpan<T>为什么需要 Span?
string text = "Hello,World";
string left = text.Substring(0, 5);Substring 的语义是:ReadOnlySpan<char>:ReadOnlySpan<char> left = text.AsSpan(0, 5);Span 的核心本质
Span<T> 从概念上可以理解成两部分:(pointer/reference, length)真正拥有数据的一块新容器Span<T> 的切片不会复制数据;Span<T> 的内容,本质上是在修改它指向的原始内存。int[] numbers = { 1, 2, 3, 4, 5 };
Span<int> span = numbers.AsSpan(1, 3);
span[0] = 99;
Console.WriteLine(numbers[1]); // 99span 的副本,而是直接改到了原数组。Span 最关键的特性:它是
ref structSpan<T> 最关键的语言层特性,不是泛型,而是它是一个:ref structSpan<T> 很高性能;Span<T>:await;Span<T> 必须保证它引用的那段内存,在使用期间始终是安全有效的。最常见的几种创建方式
1. 从数组创建
int[] array = { 1, 2, 3, 4, 5 };
Span<int> span1 = array;
Span<int> span2 = array.AsSpan();
Span<int> span3 = array.AsSpan(1, 3);span1 指向整个数组;span3 指向数组中 [2,3,4] 那一段;2. 从字符串创建只读视图
ReadOnlySpan<char>string text = "Hello,World";
ReadOnlySpan<char> span = text.AsSpan();
ReadOnlySpan<char> left = text.AsSpan(0, 5);Span 系列在业务代码里最常见的入口之一。3. 从
stackalloc 创建栈上缓冲区Span<byte> buffer = stackalloc byte[256];
buffer.Clear();GC 管理。4. 从非托管内存创建
Span<T> 也可以包装非托管内存,但这已经属于更底层的用法,通常要更谨慎。Span<T> 和 ReadOnlySpan<T> 怎么选?用
Span<T>Span<byte> bytes = stackalloc byte[4];
bytes[0] = 1;用
ReadOnlySpan<T>static int CountComma(ReadOnlySpan<char> text)
{
int count = 0;
foreach (char c in text)
{
if (c == ',')
{
count++;
}
}
return count;
}ReadOnlySpan<T>;Span<T>。最常用的几个操作
索引访问
Span<int> span = new[] { 1, 2, 3 };
int value = span[1]; // 2切片
SliceSpan<int> numbers = new[] { 1, 2, 3, 4, 5 };
Span<int> middle = numbers.Slice(1, 3); // 2,3,4复制
CopyToSpan<int> source = new[] { 1, 2, 3 };
Span<int> target = stackalloc int[3];
source.CopyTo(target);填充和清空
Span<byte> buffer = stackalloc byte[8];
buffer.Fill(0x20);
buffer.Clear();查找
ReadOnlySpan<char> text = "a,b,c".AsSpan();
int index = text.IndexOf(',');为什么
Span<T> 在字符串处理中这么有价值?string line = "Alice,18,China";
string[] parts = line.Split(',');Split 会创建数组;ReadOnlySpan<char> 可以把问题改写成“在原字符串上定位并切片”。ReadOnlySpan<char> line = "Alice,18,China".AsSpan();
int firstComma = line.IndexOf(',');
ReadOnlySpan<char> name = line[..firstComma];
ReadOnlySpan<char> rest = line[(firstComma + 1)..];
int secondComma = rest.IndexOf(',');
ReadOnlySpan<char> age = rest[..secondComma];
ReadOnlySpan<char> country = rest[(secondComma + 1)..];name、age、country:int.TryParse(age, out int parsedAge);stackalloc 和 Span<T> 是一对高频搭档Span<T>,真正开始觉得它强,是从 stackalloc 开始的。Span<char> buffer = stackalloc char[32];char;Span<char> 来安全访问它;stackalloc 适合小块、短生命周期内存;为什么
Span<T> 不能跨 await?async Task DemoAsync()
{
Span<byte> buffer = stackalloc byte[128];
await Task.Delay(1);
buffer[0] = 1;
}await 会把方法拆成状态机;Span<T> 是 ref struct,不能安全地逃逸到堆上。Memory<T>Span<T>。Span<T> 和 Memory<T> 的边界要分清Span 体系最重要的一道坎。类型 可否跨 await可否做字段 典型场景 Span<T>否 否 同步、短生命周期、高性能处理 ReadOnlySpan<T>否 否 同步只读切片 Memory<T>是 是 需要堆存活或异步边界 ReadOnlyMemory<T>是 是 只读异步场景 Span<T>;await、放字段里,用 Memory<T>。public sealed class BufferOwner
{
public Memory<byte> Buffer { get; }
public BufferOwner(byte[] bytes)
{
Buffer = bytes;
}
}Memory<byte>,但不能存 Span<byte>。Span<T>、数组、ArraySegment<T> 到底怎么选?直接用数组
ArraySegment<T>Span<T> 更早。Span<T> 现代;Span<T>Span<T> 往往比 ArraySegment<T> 更自然。和
Substring、Split 相比,Span 的价值到底在哪?Substring、Split 不能用,而是要看场景。ReadOnlySpan<char> 的优势会非常明显,因为它可以:GC 压力;一个更接近实战的例子:解析请求行
GET /api/users HTTP/1.1static (ReadOnlySpan<char> method, ReadOnlySpan<char> path, ReadOnlySpan<char> protocol)
ParseRequestLine(ReadOnlySpan<char> line)
{
int firstSpace = line.IndexOf(' ');
ReadOnlySpan<char> method = line[..firstSpace];
line = line[(firstSpace + 1)..];
int secondSpace = line.IndexOf(' ');
ReadOnlySpan<char> path = line[..secondSpace];
ReadOnlySpan<char> protocol = line[(secondSpace + 1)..];
return (method, path, protocol);
}Split(' ');Span<T> 的典型限制,一定要记住ref struct 带来的。1. 不能作为类字段
public class BadBuffer
{
public Span<byte> Buffer; // 编译错误
}2. 不能装箱
Span<int> span = stackalloc int[4];
object obj = span; // 编译错误3. 不能作为泛型集合元素
// List<Span<int>> list = new(); // 编译错误4. 不能被闭包捕获
Span<int> span = stackalloc int[4];
// Action action = () => Console.WriteLine(span[0]); // 编译错误5. 不能跨异步和迭代器边界
性能该怎么理解?不要神化 Span
Span<T> 很强,但它不是“写了就一定更快”的魔法。Span<T> 也未必有明显收益。Span<T> 很值得;Span<T>,通常没必要。一套比较稳妥的实践建议
Span<T>,下面这些建议比较实用:ReadOnlySpan<T>;Span<T>;Span<T>;await、跨字段、跨长期生命周期,用 Memory<T>;stackalloc;AsSpan() + Slice/IndexOf;Span<T>。总结
Span<T> 的本质,不是“另一个数组类型”,而是对连续内存的一层高性能视图抽象。Span<T> 是数据视图;ReadOnlySpan<T> 是只读数据视图;Memory<T> 是适合跨堆和异步边界的长期视图。.NET 项目里,只要你在做这些事情:Span<T> 几乎都是值得优先掌握的基础能力。