Span、Memory 与高性能 IO

系统讲解 .NET 的零拷贝抽象,覆盖 ref struct 与栈上切片、Span 与 Memory 的分工、ReadOnlySequence 与分段缓冲、System.IO.Pipelines 的读写模型、ArrayPool 与对象池化,以及常见的使用陷阱。

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() 抵消了零拷贝
编译报错 CS8352ref struct 逃逸到堆

避坑: 用 MemoryMarshal、Unsafe、stackalloc 的代码必须配模糊测试或边界单测,覆盖空输入、超长输入、恰好边界的输入。调试内存问题时用 dotnet-counters 看分配速率,用 dotnet-gcdump 看堆上是否有本该池化的大数组。永远记住:Span 快是因为它不拥有内存,所以谁拥有内存、活多久必须由你负责。

8. 总结

环节要点
ref structSpan 只能活在栈上,不能跨 await、不能做字段
分工同步热点用 Span,跨异步或存储用 Memory
分段缓冲ReadOnlySequence + SequenceReader 处理粘包拆包
Pipelines两阶段提交 + 背压,忘记 AdvanceTo 会 OOM
池化ArrayPool 长度不清零、不精确,Return 必须执行
零拷贝只切视图不搬数据,只在一处分配
陷阱生命周期越界无异常,需边界测试兜底

Span<T>、Memory<T> 与 Pipelines 共同构成了 .NET 的零拷贝 IO 栈。它们的收益是实打实的:解析吞吐提升数倍、GC 压力下降一个量级;代价是心智负担——生命周期、所有权、长度假设都要自己管。掌握这些抽象后,下一步是看它们在真实通信场景里的应用:SignalR 的实时连接正是建立在 Pipelines 与背压之上的。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. .NET 机器学习实战
  2. 内存剖析与 dump 分析
  3. 分布式事务与 Saga 编排