Span<T> 与 Memory<T>:把分配降到零的实践

网络编程里最常见的一行代码是 new byte[length]。它写起来毫无负担,代价却落在 GC 上: 每秒几千个这样的数组,就等于给 Gen0 不停地加料。当我在一个长连接服务里把这条路径改成 Span 之后, 分配从每次 5 KB 降到 0 B,单次解析耗时降到原来的四分之一。这篇笔记就把这个过程完整写下来。

一、先量化:一次解析到底分配了什么

假设报文格式是「4 字节小端长度 + 载荷」。最容易写出的版本是这样的:

public static int ParseHeader(byte[] buffer)
{
    var lengthBytes = new byte[4];                    // 分配 1
    Array.Copy(buffer, 0, lengthBytes, 0, 4);
    var length = BitConverter.ToInt32(lengthBytes, 0);

    var payload = new byte[length];                   // 分配 2
    Array.Copy(buffer, 4, payload, 0, length);

    return payload.Length + length;
}

在单连接、低频调用的场景下这没有任何问题。可一旦它出现在每帧、每包都会走的热路径上, 两次堆分配会直接反映成 GC 次数。用 BenchmarkDotNet 跑一下(.NET 10,Release,i5 平台):

MethodMeanGen0Allocated
ParseWithArray1,842.6 ns1.22005.13 KB
ParseWithSpan412.9 ns-0 B

时间差了 4 倍多,但真正值得注意的不是时间,而是 Allocated 那一列: 零分配意味着这个方法不产生任何 GC 压力,它的吞吐不会随负载上升而突然崩塌。

二、用 Span 改写:切片不复制

Span<T> 本质上只是一个 (引用, 长度) 的结构体。 Slice 不会复制数据,只是换了一对指针和长度:

using System.Buffers.Binary;

public static int ParseHeader(ReadOnlySpan<byte> buffer)
{
    // 直接从缓冲区读,不需要中间数组
    var length = BinaryPrimitives.ReadInt32LittleEndian(buffer);

    // 只是视图,没有复制
    var payload = buffer.Slice(4, length);

    return payload.Length + length;
}

调用方仍然可以传 byte[],因为数组有到 Span 的隐式转换。这样一来, 方法签名反而更通用了:数组、ArraySegment、栈上内存、非托管指针包装出来的 Span 都能直接传进来。

栈上临时缓冲:stackalloc

有些操作确实需要一个临时工作区,比如格式化数字、拼一小段定长头。这时候不要 new byte[],用栈:

public static bool TryWriteHeader(Span<byte> destination, int length, out int written)
{
    Span<byte> scratch = stackalloc byte[8];   // 栈上,随方法返回自动释放
    BinaryPrimitives.WriteInt32LittleEndian(scratch, length);

    if (destination.Length < 4)
    {
        written = 0;
        return false;
    }

    scratch[..4].CopyTo(destination);
    written = 4;
    return true;
}

注意 stackalloc 的容量必须是常量或很小的值,它占用的是线程栈。 如果长度来自外部输入,一定要先做上限校验,否则就是一次栈溢出,进程直接终止,连异常都抓不到。

三、ref struct 的限制:为什么不能跨 await

Span<T> 被声明为 ref struct,编译器强制它只能活在栈上。 原因很直接:它内部存的是指向内存的托管引用,如果它被装箱、被存进字段、被搬到堆上, GC 就无法正确追踪这段引用,随时可能指向已被回收或移动的内存。

于是下面这段代码无法通过编译:

async Task<int> BadAsync()
{
    Span<byte> buf = stackalloc byte[32];
    await Task.Delay(10);     // error CS4013: 不能在 async 方法中使用 ref struct
    return buf[0];
}

因为 async 方法会被改写成状态机(见 上一篇),局部变量要提升为状态机的字段, 而状态机是堆上的 class。Span 上不去,编译器只能拒绝。

此时正确的替代是 Memory<T>:

async Task<int> GoodAsync(Memory<byte> memory)
{
    await Task.Delay(10);
    return memory.Span[0];    // 需要时再取出 Span,用完即弃
}
Span<T>Memory<T>
存储位置只能在栈上(ref struct)可在堆上,可作字段、可跨 await
能否放进 async 方法不能能
开销零,纯引用 + 长度极小,多一层间接
典型用途同步解析、格式化、切片异步管线、缓冲区字段

四、什么时候不该用 Span

Span 不是万能的,用错地方反而让代码变难读。以下场景我仍然用普通数组或集合:

  • 数据要长期持有——要存进字段、缓存、队列,就必须是 Memory<T> 或数组。
  • 需要放进 List / Dictionary——泛型集合不能以 ref struct 为元素。
  • 方法内部没有明显分配热点——为了 Span 而 Span,只会增加阅读成本,收益为 0。

判断标准很朴素:先跑一次分配分析,看到热点再改。 用 dotnet-counters monitor --counters System.Runtime 观察 alloc-rate, 或者直接看 BenchmarkDotNet 的 Allocated 列,比自己猜准得多。

小结

  • Span<T> 是「引用 + 长度」的视图,Slice 不复制,接口边界用它比数组更通用。
  • stackalloc 适合小的定长临时缓冲,但必须先校验长度,避免栈溢出。
  • ref struct 不能跨 await,异步场景改用 Memory<T>,需要时再取 .Span。
  • 改造前先量化,零分配只在热路径上才有意义。
上一篇 async/await 到底在等什么:从状态机到 ConfigureAwait