1. ref struct 与栈上切片
一句话总结:
Span<T>是只能活在栈上的 ref struct,它把「一段连续内存」抽象成值类型切片,让字符串与数组的解析不再需要拷贝。
Span<T> 的核心价值是不拥有内存。它只是一对指针加长度,指向某块已有的连续内存——可能是数组、栈上缓冲、非托管内存,或字符串内部的字符数据。切片、截断、查找都在原内存上做,不产生新分配。
它是 ref struct,这带来一组硬性约束:不能装箱、不能作为类字段、不能在 async 方法里跨越 await 使用、不能放进 List<T>。原因很直接——它可能指向栈内存,一旦逃逸出栈帧就会变成悬空引用。
// 解析 "key=value;key2=value2",全程零分配
public static int ParsePairs(ReadOnlySpan<char> input, Span<KeyValuePair<string, string>> output)
{
int count = 0;
while (!input.IsEmpty)
{
int sep = input.IndexOf('=');
if (sep < 0) break;
ReadOnlySpan<char> key = input[..sep];
input = input[(sep + 1)..];
int end = input.IndexOf(';');
ReadOnlySpan<char> value = end < 0 ? input : input[..end];
input = end < 0 ? ReadOnlySpan<char>.Empty : input[(end + 1)..];
// 只有最终要留存时才分配字符串
output[count++] = new KeyValuePair<string, string>(key.ToString(), value.ToString());
}
return count;
}
// 栈上缓冲:小数据完全不碰堆
Span<byte> buffer = stackalloc byte[256];
int written = Encoding.UTF8.GetBytes("hello", buffer);
// 切片不产生拷贝
ReadOnlySpan<byte> slice = buffer[..written];
| 类型 | 是否 ref struct | 能否跨 await | 典型用途 |
|---|---|---|---|
Span<T> | 是 | 否 | 同步解析、格式化 |
ReadOnlySpan<T> | 是 | 否 | 只读切片、字符串视图 |
Memory<T> | 否 | 是 | 异步缓冲、字段存储 |
ReadOnlyMemory<T> | 否 | 是 | 不可变缓冲 |
避坑:
Span<T>不能出现在async方法体内跨越await的位置,也不能作为 lambda 捕获(闭包会把它变成字段)。要在异步流程里用切片,就在await前把需要的数据转成Memory<T>或字符串。stackalloc的大小要克制,超过几 KB 就有栈溢出风险,大缓冲应改用ArrayPool。
2. Span 与 Memory 的分工
一句话总结:
Span<T>用于同步、栈受限的高频路径;Memory<T>用于需要存储或跨异步的场合,Memory.Span在同步段里再拿回切片能力。
两者的关系是:Memory<T> 是「可存储、可跨 await」的版本,内部可能包装数组、字符串或自定义 MemoryManager<T>。它不能直接切片做原地操作,但可以通过 .Span 拿到一个 Span<T>,在同步代码段里高速处理。
分工原则很简单:能同步用 Span,必须异步或必须存字段用 Memory。
// 异步读入 + 同步解析
public static async Task<int> ReadAndParseAsync(Stream stream, CancellationToken ct)
{
// Memory<byte> 可以跨 await
Memory<byte> buffer = new byte[4096];
int read = await stream.ReadAsync(buffer, ct);
// 同步段里用 Span 处理
return CountLines(buffer.Span[..read]);
}
private static int CountLines(ReadOnlySpan<byte> data)
{
int lines = 0;
while (true)
{
int nl = data.IndexOf((byte)'\n');
if (nl < 0) return lines + (data.IsEmpty ? 0 : 1);
lines++;
data = data[(nl + 1)..];
}
}
| 需求 | 选择 | 理由 |
|---|---|---|
| 同步解析热点 | Span<T> | 无分配、无逃逸风险 |
跨 await 传递 | Memory<T> | 可存储、可异步 |
| 类字段缓存 | Memory<T> | ref struct 不能做字段 |
| 只读视图 | ReadOnlySpan<T> / ReadOnlyMemory<T> | 明确不可变 |
| 自定义内存源 | MemoryManager<T> | 桥接非托管或池化内存 |
避坑:
Memory<T>.Span每次访问都会做一次有效性检查,在循环里反复调用是浪费——先取一次var span = memory.Span;再循环。另一个常见错误是把Memory<byte>当作「一定能拿到底层数组」,MemoryMarshal.TryGetArray才是正确且安全的探测方式,直接MemoryMarshal.GetArrayDataReference越界访问会直接破坏内存。
3. ReadOnlySequence 与分段缓冲
一句话总结:
ReadOnlySequence<T>表示由多段内存拼成的逻辑连续序列,是网络与管道场景下处理「跨包消息」的标准抽象。
网络数据到达时天然是分段的:一次 read 可能只拿到半条消息,下一条消息可能又和前一条粘在一起。若每次都把数据拷进一个大缓冲再处理,拷贝成本会随吞吐线性上升。
ReadOnlySequence<T> 用链表式的 ReadOnlyMemory<T> 片段表示逻辑连续的字节流。配合 SequenceReader<T>,可以在不拷贝的前提下顺序消费、探测分隔符、跨段读取整数。
// 从分段序列里读出一条以 \n 结尾的消息
public static bool TryReadLine(ref SequenceReader<byte> reader, out string line)
{
line = string.Empty;
if (!reader.TryReadTo(out ReadOnlySequence<byte> seq, (byte)'\n', advancePastDelimiter: true))
return false;
line = Encoding.UTF8.GetString(seq); // 只在成行时才分配
return true;
}
// 跨段读取定长整数(大端)
public static bool TryReadInt32(ref SequenceReader<byte> reader, out int value)
{
if (reader.Remaining < 4) { value = 0; return false; }
Span<byte> tmp = stackalloc byte[4];
reader.TryCopyTo(tmp);
reader.Advance(4);
value = BinaryPrimitives.ReadInt32BigEndian(tmp);
return true;
}
| API | 作用 |
|---|---|
SequencePosition | 序列中的位置游标 |
SequenceReader<T> | 顺序读取器,跨段自动处理 |
TryReadTo | 读到指定分隔符 |
SequenceReader.UnreadSpan | 当前段剩余的直接视图 |
IsSingleSegment | 判断是否只有一段(可走快路径) |
避坑: 不要对
ReadOnlySequence<T>直接ToArray()——那等于把分段优势全丢掉,还会在大消息上产生大对象堆分配。需要连续视图时先判断IsSingleSegment,是单段就直接用FirstSpan,多段才考虑拷贝。另外SequenceReader<T>是ref struct,跨await同样受限,需在同步段内消费完或把位置转成SequencePosition保存。
4. System.IO.Pipelines 的读写模型
一句话总结: Pipelines 把「读」和「写」解耦成
PipeReader与PipeWriter,用背压与两阶段提交替代手工管理缓冲,是 Kestrel 与 SignalR 的底层 IO 抽象。
传统 Stream 的问题是:缓冲该多大、读到的数据属于谁、什么时候可以复用——全要调用方自己管。System.IO.Pipelines 把这些责任内置:写入方 Advance 声明写了多少,读取方 AdvanceTo 声明消费了多少,管道据此自动回收与背压。
关键概念是两阶段提交:ReadAsync 拿到 ReadResult,其中 Buffer 是 ReadOnlySequence<byte>,IsCompleted 表示写入方已关闭。处理完必须调用 AdvanceTo(consumed, examined) 告诉管道哪些字节已消费、哪些已检查但未消费。
public static async Task ProcessLinesAsync(PipeReader reader, CancellationToken ct)
{
while (true)
{
ReadResult result = await reader.ReadAsync(ct);
ReadOnlySequence<byte> buffer = result.Buffer;
SequencePosition consumed = buffer.Start;
SequencePosition examined = buffer.End;
try
{
if (TryParseMessages(ref buffer, out consumed, out examined, out var messages))
{
foreach (var msg in messages) await HandleAsync(msg, ct);
}
if (result.IsCompleted && buffer.IsEmpty) break;
}
finally
{
// 必须调用,否则管道不会回收内存
reader.AdvanceTo(consumed, examined);
}
}
await reader.CompleteAsync();
}
// 写入方
public static async Task WriteAsync(PipeWriter writer, CancellationToken ct)
{
Span<byte> span = writer.GetSpan(64); // 借用可写内存
int written = Encoding.UTF8.GetBytes("PING\r\n", span);
writer.Advance(written); // 声明写了多少
FlushResult fr = await writer.FlushAsync(ct); // 可能因背压等待
if (fr.IsCompleted) { /* 读取方已关闭 */ }
}
| 概念 | 含义 |
|---|---|
AdvanceTo(consumed, examined) | 消费到哪、检查到哪 |
examined 只到消息边界 | 未完整消息下次再处理 |
FlushAsync 返回 IsCompleted | 读取方关闭信号 |
PipeOptions 阈值 | 控制背压触发点 |
reader.CompleteAsync() | 释放管道资源 |
避坑: 最常见的错误是不调用
AdvanceTo就continue,导致管道缓冲区永不回收,内存一路涨到 OOM。其次是examined传了buffer.End却只消费了一部分——那会让管道以为后面还有数据可读,造成重复处理。正确做法是把examined设为「已确认完整的那部分边界」,剩余数据留待下次。
5. ArrayPool 与对象池化
一句话总结:
ArrayPool<T>与MemoryPool<T>复用大数组,ObjectPool<T>复用昂贵对象,把高频分配从 GC 压力变成池命中。
ArrayPool<T>.Shared 是进程级共享池,按 2 的幂次分级管理数组。借出用 Rent,归还用 Return。归还时可以传 clearArray 决定是否清零——涉及敏感数据必须清零。
对 Span/Memory 场景,MemoryPool<T> 返回 IMemoryOwner<T>,与 Memory<T> 天然配合。
// 数组池:避免每次都分配大缓冲
public static async Task<int> CopyAsync(Stream src, Stream dst, CancellationToken ct)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(81920);
try
{
int total = 0, read;
while ((read = await src.ReadAsync(buffer.AsMemory(0, 81920), ct)) > 0)
{
await dst.WriteAsync(buffer.AsMemory(0, read), ct);
total += read;
}
return total;
}
finally
{
// 必须归还;含敏感数据时 clearArray: true
ArrayPool<byte>.Shared.Return(buffer);
}
}
// 对象池:复用 StringBuilder、序列化器、连接等昂贵对象
private static readonly ObjectPool<StringBuilder> Pool =
new DefaultObjectPool<StringBuilder>(new StringBuilderPooledObjectPolicy());
public static string Build(IEnumerable<string> parts)
{
StringBuilder sb = Pool.Get();
try
{
foreach (var p in parts) sb.Append(p);
return sb.ToString();
}
finally
{
Pool.Return(sb); // 归还前策略会重置状态
}
}
| 池 | 复用对象 | 归还方式 | 注意 |
|---|---|---|---|
ArrayPool<T> | T[] | Return(array) | 长度可能大于请求值 |
MemoryPool<T> | IMemoryOwner<T> | Dispose() | 与 Memory 配合 |
ObjectPool<T> | 任意对象 | Return(obj) | 需重置策略 |
RecyclableMemoryStream | 内存流 | Dispose() | 大流场景 |
避坑:
Rent返回的数组长度可能大于请求值,且内容不清零。永远不要假设它全是 0,也不要假设长度刚好等于申请值——必须显式AsMemory(0, size)或用ArraySegment限定范围。另一个坑是把Rent的数组直接返回给调用方长期持有,那会让池失去意义,同时归还后原引用会变成脏数据。
6. 零拷贝解析实战
一句话总结: 零拷贝的核心是「只切视图不搬数据,只在必须留存时才分配」,典型收益在高频协议解析与日志处理上。
一个 HTTP 请求行的解析是经典例子。朴素写法反复 Split 产生大量字符串;零拷贝写法全程在 ReadOnlySpan<char> 上切片,只在最后把需要的字段转成字符串。
// 解析 "GET /api/orders?id=42 HTTP/1.1"
public static bool TryParseRequestLine(
ReadOnlySpan<char> line,
out string method, out string path, out string version)
{
method = path = version = string.Empty;
int s1 = line.IndexOf(' ');
if (s1 <= 0) return false;
int s2 = line[(s1 + 1)..].IndexOf(' ');
if (s2 <= 0) return false;
s2 += s1 + 1;
ReadOnlySpan<char> m = line[..s1];
ReadOnlySpan<char> p = line[(s1 + 1)..s2];
ReadOnlySpan<char> v = line[(s2 + 1)..];
// 只在这里分配
method = m.ToString();
path = p.ToString();
version = v.ToString();
return true;
}
| 手法 | 效果 |
|---|---|
ReadOnlySpan<char> 切片 | 无字符串分配 |
MemoryExtensions.IndexOf | 向量化查找 |
int.TryParse(span, out _) | 直接从切片解析数字 |
Encoding.UTF8.GetBytes(span, buffer) | 直接写入目标缓冲 |
string.Create | 一次性构造最终字符串 |
避坑:
ReadOnlySpan<char>.ToString()是唯一的分配点,别在循环里对每个 token 都调用。数字解析优先用int.TryParse(ReadOnlySpan<char>, out int)而不是先ToString()再解析——后者会白白分配。格式化输出优先TryFormat到Span<char>,避免string.Format的中间分配。
7. 常见陷阱与调试
一句话总结: 零拷贝代码的 bug 多为「生命周期越界」与「长度假设错误」,特点是能跑通测试但在压力下静默出错。
这类代码的危险在于没有异常。悬空引用、越界切片、脏缓冲都不会立刻报错,而是产出错误数据或在 GC 后崩溃。
// 陷阱 1:Span 逃逸(编译期就会拦住)
// private Span<byte> _field; // 编译错误
// 陷阱 2:async 中跨 await 使用 Span(编译错误)
// async Task Bad() { Span<byte> s = stackalloc byte[8]; await Task.Yield(); Use(s); }
// 陷阱 3:假设 Rent 的数组是干净的
byte[] buf = ArrayPool<byte>.Shared.Rent(1024);
// buf 里可能是上一次的残留数据,必须先写入或清零
// 陷阱 4:忘记 Return,池退化
// 每次 Rent 都拿新数组,池形同虚设
| 症状 | 可能根因 |
|---|---|
| 偶发错误数据 | 使用未清零的池化缓冲 |
| GC 后崩溃 | Span 指向已回收内存 |
| 内存持续增长 | 忘记 Return 或 AdvanceTo |
| 性能不升反降 | 频繁 ToArray() 抵消了零拷贝 |
| 编译报错 CS8352 | ref struct 逃逸到堆 |
避坑: 用
MemoryMarshal、Unsafe、stackalloc的代码必须配模糊测试或边界单测,覆盖空输入、超长输入、恰好边界的输入。调试内存问题时用dotnet-counters看分配速率,用dotnet-gcdump看堆上是否有本该池化的大数组。永远记住:Span快是因为它不拥有内存,所以谁拥有内存、活多久必须由你负责。
8. 总结
| 环节 | 要点 |
|---|---|
| ref struct | Span 只能活在栈上,不能跨 await、不能做字段 |
| 分工 | 同步热点用 Span,跨异步或存储用 Memory |
| 分段缓冲 | ReadOnlySequence + SequenceReader 处理粘包拆包 |
| Pipelines | 两阶段提交 + 背压,忘记 AdvanceTo 会 OOM |
| 池化 | ArrayPool 长度不清零、不精确,Return 必须执行 |
| 零拷贝 | 只切视图不搬数据,只在一处分配 |
| 陷阱 | 生命周期越界无异常,需边界测试兜底 |
Span<T>、Memory<T> 与 Pipelines 共同构成了 .NET 的零拷贝 IO 栈。它们的收益是实打实的:解析吞吐提升数倍、GC 压力下降一个量级;代价是心智负担——生命周期、所有权、长度假设都要自己管。掌握这些抽象后,下一步是看它们在真实通信场景里的应用:SignalR 的实时连接正是建立在 Pipelines 与背压之上的。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。