网络编程里最常见的一行代码是 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 平台):
| Method | Mean | Gen0 | Allocated |
|---|---|---|---|
| ParseWithArray | 1,842.6 ns | 1.2200 | 5.13 KB |
| ParseWithSpan | 412.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。- 改造前先量化,零分配只在热路径上才有意义。