视图不拥有底层存储,ReadOnlySpan<T>也不会让所有者变成不可变;底层缓冲区被修改、归还到池中或释放后,旧视图可能读到变化或失效的数据。
同步、短生命周期的访问使用Span<T>;需要存入字段或跨越await时传递Memory<T>,并把所有者、有效长度与使用期限写进API契约。
是什么,为什么存在
Span<T>是连续T元素区域上的可读写视图。它可以引用数组的一段、由 stackalloc 创建的栈内存,或者受控的非托管内存。视图本身只描述一段区域,不接管该区域的所有权。
切片、协议解析和缓冲区处理经常只需要原数据的一部分。若每一步都创建子数组或子字符串,代码会复制数据并产生新对象;span让调用方把“这段现有数据”直接交给同步方法。只有后续显式调用ToArray()、ToString()等物化操作时,才会得到独立副本。
ReadOnlySpan<T>去掉通过该视图写入的能力,适合只读参数和字符串片段。它表达的是访问权限,不是深层不可变性:另一个别名仍可修改同一数组,而已有的只读视图会观察到修改。
Memory<T>与ReadOnlyMemory<T>表示可以存储的连续内存区域。它们不是 ref struct ,因此能作为类字段,也能跨异步暂停点保留;实际同步读写时,通过.Span取得短生命周期视图。
这四个类型解决的是视图和生命周期表达,不是自动的所有权管理。数组拥有自身存储,字符串管理自身字符,IMemoryOwner<T>管理租用区域;Span<T>或Memory<T>只借用相应区域。API必须另外说明谁能修改、谁负责释放,以及借用何时结束。
选择入口参数时,先问方法是否只在当前同步调用内使用数据。答案为是时,优先接受ReadOnlySpan<T>或Span<T>,这样数组、字符串和其他连续来源都能调用;若方法需要保存参数或在await后继续使用,则接受ReadOnlyMemory<T>或Memory<T>。
工作原理
视图由区域决定
可以把一个视图理解为底层存储、起始位置和长度的组合。Slice(start, length)或范围运算符创建新视图,新旧视图仍指向同一存储。切片的索引从当前视图起点计算,而不是从原始所有者的起点计算。
运行时仍会检查视图边界。构造或切片超出合法范围会抛出异常,索引访问也不能越过视图长度。类型安全和边界检查并不验证业务协议,例如“报文至少有8字节”仍应由代码先检查。
空视图是正常值。Span<T>.Empty、default(Span<T>)以及长度为零的切片都可用IsEmpty检查;不要把空视图自动解释为失败,除非API契约明确这样规定。
可写性与来源分开
视图的类型决定当前调用方能否写入,来源决定数据是否可能由其他路径改变。ReadOnlySpan<char>指向字符串时,字符串自身不可变;同一类型指向数组时,数组所有者仍可修改元素。这两个事实不能只从参数类型中合并推断。
| 类型 | 可通过视图写入 | 可存为普通字段 | 可跨await保留 | 常见用途 |
|---|---|---|---|---|
Span<T> | 是 | 否 | 否 | 同步修改一段缓冲区 |
ReadOnlySpan<T> | 否 | 否 | 否 | 同步解析或比较 |
Memory<T> | 是,通过.Span | 是 | 是 | 异步可写缓冲区 |
ReadOnlyMemory<T> | 否,通过.Span读取 | 是 | 是 | 异步只读数据 |
Span<T>可以隐式转换为ReadOnlySpan<T>,Memory<T>也可以转换为ReadOnlyMemory<T>。反向转换不存在,因为它会凭空增加写权限。需要可写访问时,应让所有者明确提供可写视图。
ref struct限制逃逸
Span<T>和ReadOnlySpan<T>是ref struct,编译器用引用安全分析阻止视图逃出底层存储的有效范围。例如,指向当前方法stackalloc区域的span不能返回给调用方,也不能装箱为object。
普通类不能保存span字段,lambda也不能捕获span,因为这些对象可能比当前栈帧活得更久。需要保存区域时,保存数组、字符串、Memory<T>或拥有该区域的对象,并在每次同步访问时重新取得.Span。
C# 13起放宽了部分规则,C# 14延续这些能力:ref struct可以出现在异步或迭代器方法中,但所有使用都必须局限在不含await或yield的代码块内;它也能用于带allows ref struct反约束的泛型。旧文章中的“span绝不能出现在async方法”已经不准确,但把它保留到暂停点之后仍不合法。
Memory<T>穿过异步边界
异步方法应让Memory<T>穿过暂停点,再把同步处理放进接收Span<T>的辅助方法。这样,编译器看到的span生命周期只覆盖一次不会暂停的调用,而可存储的memory负责描述之后仍需访问的区域。
这并不延长底层所有者的寿命。若Memory<T>来自IMemoryOwner<T>,所有者必须保持未释放状态;若来自ArrayPool<T>租出的数组,数组在所有消费者完成之前不能归还。返回一个指向已归还数组的Memory<T>是生命周期错误,不是类型系统能自动拦住的错误。
异步API还要区分容量与有效数据长度。池可能给出比请求更大的缓冲区,I/O操作也可能只填充前一部分;应把memory[..written]传给消费者,而不是暴露整段容量。
所有权是额外契约
Microsoft的使用指南把内存管理看作所有者、消费者与租期。所有者负责最终释放或归还存储,消费者在租期内读取或写入,租期规定视图何时仍有效。这三个角色可以由同一方法承担,也可以跨组件分开。
接受Memory<T>的方法不应默认获得释放权。除非API明确转移所有权,否则参数仍由调用方拥有;被调用方只能在约定期间借用。与流类似,是否释放不能靠类型名猜测。
公开API若要在返回后继续持有调用方内存,需要明确说明保留行为。调用方若无法保证生命周期,可先复制到自己拥有的数组;这次复制是有意建立所有权边界,而不是span使用失败。
示例
下面三个例子依次展示数组别名、无子字符串解析,以及跨异步边界保存Memory<T>。本地环境没有 .NET SDK或C#编译器,因此每个代码块都带有仓库约定的未执行标记,没有伪造控制台输出。
修改数组切片
数组切片仍与原数组共享元素。排序只发生在三元素视图范围内,但结果直接出现在原数组中。
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
int[] readings = [12, 18, 21, 30, 45];
Span<int> window = readings.AsSpan(1, 3);
window[0] = 20;
window.Sort();
Console.WriteLine(string.Join(", ", window.ToArray()));
Console.WriteLine(string.Join(", ", readings));
Console.WriteLine(window.Length);# not executed here: the .NET SDK and C# compilers are unavailableToArray()只用于把当前视图交给string.Join并显示;它会创建副本,不属于切片本身。业务代码若只需遍历或传给另一个span API,就不应为了方便而先物化。
把window声明为ReadOnlySpan<int>会阻止window[0] = 20和Sort(),但不会隔离readings。原数组仍是可写所有者。
解析字符片段
解析器先定位冒号,再把左右两段直接交给int.TryParse(ReadOnlySpan<char>, ...)。失败路径把两个输出重置为零,调用方不会收到半解析结果。
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
foreach (string record in new[] { "17:245", "bad", "8:x" })
{
bool ok = TryParseReading(record.AsSpan(), out int sensorId, out int value);
Console.WriteLine($"{record} -> {ok}, {sensorId}, {value}");
}
static bool TryParseReading(
ReadOnlySpan<char> record,
out int sensorId,
out int value)
{
int separator = record.IndexOf(':');
if (separator <= 0 || separator == record.Length - 1)
{
sensorId = 0;
value = 0;
return false;
}
bool idOk = int.TryParse(record[..separator], out sensorId);
bool valueOk = int.TryParse(record[(separator + 1)..], out value);
return idOk && valueOk;
}# not executed here: the .NET SDK and C# compilers are unavailable这里没有调用Substring()。输入字符串仍然拥有字符,两个切片只在TryParseReading的同步调用中存在;方法返回后也没有保存任何视图。
真实协议通常还要规定空白、符号、数值范围和多余分隔符。span只避免不需要的中间副本,不会替你定义这些输入规则。
在异步方法中保存Memory<T>
Memory<byte>可以在await前取得并在之后继续使用。实际修改交给同步辅助方法,避免任何Span<byte>跨越暂停点。
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
using System.Text;
using System.Threading.Tasks;
byte[] payload = Encoding.ASCII.GetBytes("aBc-19");
await NormalizeAsciiAsync(payload.AsMemory());
Console.WriteLine(Encoding.ASCII.GetString(payload));
static async Task NormalizeAsciiAsync(Memory<byte> payload)
{
await Task.Yield();
NormalizeAscii(payload.Span);
}
static void NormalizeAscii(Span<byte> bytes)
{
for (int index = 0; index < bytes.Length; index++)
{
byte current = bytes[index];
if (current is >= (byte)'A' and <= (byte)'Z')
{
bytes[index] = (byte)(current + ('a' - 'A'));
}
}
}# not executed here: the .NET SDK and C# compilers are unavailable数组由调用方拥有,所以在任务完成后仍有效,修改也能从payload观察到。若方法只应读取数据,把两个参数分别改为ReadOnlyMemory<byte>和ReadOnlySpan<byte>,让签名表达权限。
如果来源改为池化内存,调用方必须等待NormalizeAsciiAsync完成后才能归还缓冲区。仅仅把数组包装为Memory<byte>不会建立租期或引用计数。
为临时缓冲区设置边界
同一个同步算法可以在小输入时使用stackalloc,在较大输入时租用数组。两条路径都产生Span<byte>,所以求和逻辑不需要复制两份。
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
using System.Buffers;
byte[] small = [3, 1, 4, 1, 5];
byte[] large = new byte[80];
large.AsSpan().Fill(2);
Console.WriteLine(Checksum(small));
Console.WriteLine(Checksum(large));
static int Checksum(ReadOnlySpan<byte> source)
{
const int StackLimit = 64; // 这是示例策略,不是通用阈值。
byte[]? rented = null;
try
{
Span<byte> scratch = source.Length <= StackLimit
? stackalloc byte[source.Length]
: (rented = ArrayPool<byte>.Shared.Rent(source.Length))
.AsSpan(0, source.Length);
source.CopyTo(scratch);
int checksum = 0;
foreach (byte value in scratch)
{
checksum += value;
}
return checksum;
}
finally
{
if (rented is not null)
{
ArrayPool<byte>.Shared.Return(rented, clearArray: true);
}
}
}# not executed here: the .NET SDK and C# compilers are unavailable租出的数组可能大于source.Length,因此代码立即切成准确的逻辑长度。finally保证异常路径也归还数组;这里选择清除,是因为示例把临时内容视为不应泄漏给下一位租用者的数据。
StackLimit只展示控制流,不是可移植的性能结论。生产代码应根据调用深度、输入上限和基准决定阈值;若复制本身没有业务需要,就应直接在source上计算,而不是为了使用span再造缓冲区。
陷阱
把切片当成副本
修复方法: 需要独立数据时显式调用ToArray(),并把复制写进变量名或API文档。只需要借用时保留视图,同时测试对切片的写入是否应反映到所有者。
把只读视图当成不可变数据
修复方法: 若消费者需要稳定快照,就复制到自己拥有的存储;若允许共享变化,则在契约中说明。不要用ReadOnlyMemory<T>代替所有权和并发同步设计。
让span逃出安全范围
修复方法: 保存所有者或Memory<T>,在紧邻同步使用的位置取得.Span。异步方法中即使某个局部写法能编译,也要确认span在任何暂停点都不再存活。
返回已经归还或释放的内存
修复方法: 要么在所有者作用域内完成消费,要么把所有者连同内存一起转交,并明确由谁释放。若API只返回数据且不能表达租期,就返回调用方拥有的副本。
对不受限长度使用stackalloc
修复方法: 设定经过评审的小型上限,超出时改用数组或池,并在读取前初始化所需区域。阈值应由真实调用深度与测试决定,不能照抄一个通用常数。
生命周期与所有权契约
内存API的难点通常不是如何切片,而是谁允许把视图保留多久。一个同步方法若只在返回前读取参数,ReadOnlySpan<T>已经把租期限制得很窄;若对象要把输入存进字段,签名必须改为可存储类型,并说明它是保留调用方内存还是复制。
拥有者与可写者也不是同义词。方法可以拥有一块只读发布的数据,也可以借用调用方提供的可写缓冲区。文档最好分别回答“谁释放”“谁可写”“何时失效”,不要用含糊的“方法处理这块内存”代替。
对IMemoryOwner<T>,Dispose()结束所有者提供的租期。消费者不应缓存.Memory或.Span到该点之后;即使之后的读取碰巧仍返回旧字节,也不构成受支持行为。
对ArrayPool<T>,租出的数组可能含有前一位使用者留下的数据,也可能大于请求长度。只处理逻辑范围,在安全边界需要时清除敏感部分,并保证一次租用只归还一次。清除策略取决于数据敏感性,不应假设池会自动清零。
API若需要转移租用所有权,返回一个同时携带Memory<T>和Dispose()责任的对象,比单独返回Memory<T>更诚实。若消费方不适合管理资源,生产方应在自己的租期内完成回调,或者承担复制成本后返回独立数据。
并发不会因换成只读视图而消失。一个线程通过ReadOnlyMemory<T>读取数组时,另一个线程仍可经原数组写入;若协议需要一致快照,应使用同步、所有权转移或复制,而不是依赖类型名中的ReadOnly。
API签名就是租期说明
参数类型应选择调用方必须满足的最弱能力,而不是实现当前碰巧使用的具体来源。只读同步算法接受ReadOnlySpan<T>,可写同步算法接受Span<T>;这既限制权限,也避免强迫调用方先构造数组。
需要保留输入的API不能接受span后偷偷复制却不说明。复制可能完全合理,但它改变了成本和身份语义;方法名、文档或返回类型应让调用方知道数据是否被保留,以及之后对原缓冲区的修改是否可见。
| API行为 | 合适的形状 | 必须补充的契约 |
|---|---|---|
| 仅在同步调用内读取 | ReadOnlySpan<T>参数 | 空输入与格式规则 |
| 仅在同步调用内修改 | Span<T>参数 | 哪些位置会被写入 |
跨await使用调用方数据 | ReadOnlyMemory<T>/Memory<T>参数 | 保留期限与并发修改规则 |
| 交付池化结果 | 携带Memory<T>的可释放所有者 | 有效长度与释放责任 |
返回ReadOnlyMemory<T>也不意味着返回独立数据。如果它指向对象内部数组,调用方得到的是该对象状态的实时窗口;后续方法调用可能改变其内容。需要快照语义时,返回独立存储并明确这一点。
回调可以把租期限制在一次调用内。生产者可在拥有者仍有效时调用参数为ReadOnlySpan<T>的自定义同步委托,回调不能把span安全地保存到之后;但回调重入、异常与线程切换仍需由API单独规定。
设计签名前,写下三个具体答案:
- 底层存储由谁拥有,又由谁负责释放或归还?
- 哪一段元素有效,消费者能否修改,修改对谁可见?
- 租期在同步返回、任务完成、下一次读取还是显式
Dispose()时结束?
这三项能直接生成边界测试。让拥有者提前结束、让I/O只写入部分容量、让另一个别名在租期内修改,再确认API是拒绝、复制、同步还是明确允许这些情况。
这种契约也决定命名。Borrow、Rent、Copy与Own应对应真实生命周期,不能只作为听起来高效的后缀。
栈分配与边界选择
stackalloc适合生命周期严格局限在当前调用内的临时连续缓冲区。把结果直接赋给Span<T>,可以在安全代码中使用索引、切片和常见span API,无需暴露指针。
栈空间有限,而且同一方法中的分配通常到方法返回时才释放。在循环中反复stackalloc,即使单次大小不大,也可能累积成栈溢出;应在循环外复用一个缓冲区,或改用其他存储。
长度来自外部输入时,先验证非负值和上限。小型路径可以使用stackalloc,大型路径可使用新数组或池;两条路径都收敛成Span<T>后,核心算法无需知道存储来源。
不要把“在栈上”直接等同于“更快”。选择应先满足生命周期、最大尺寸和初始化规则,再由代表性基准判断是否值得增加分支或池管理。没有测量时,只陈述切片不复制这一语义事实。
调用接收span的方法时,编译器会防止明显的栈引用逃逸,但它无法验证协议层面的长度、编码或并发约束。仍要测试空输入、边界长度、重叠区域,以及底层所有者在调用期间被修改的情况。
Span<T>.CopyTo正确定义了重叠源与目标的复制行为,但自写循环可能覆盖尚未读取的数据。需要搬移重叠区域时优先用标准API;需要独立快照时,复制完成后也不要继续把原视图当作唯一数据源。
4个问题 · 2道输出预测题 · 1道找错题