C#高性能内存处理:Span与Memory实战解析
C# 这门语言在 .NET 生态里跑了这么多年我一直觉得性能问题最迷人的地方不是单线程跑得多快而是怎么在托管运行时里写出接近原生的代码。标题里的 Span 和 Memory就是 .NET 高性能内存处理的两根台柱子。如果你写过上位机、解析过西门子 PLC 的报文、处理过摄像头回调里的帧数据或者只是用 C# 写一个频繁分配字符串的小服务大概率体会过 GC 抖动和内存拷贝带来的憋屈明明逻辑很简单性能就是上不去。Span 和 Memory 就是专门治这个病的。我会从内存分配和 GC 的角度先说清楚为什么需要这两个类型再拆解 Span 的底层机制、Memory 的异步场景最后用几个可以抄作业的实战例子配合 BenchmarkDotNet 验证收益。这篇内容适合正在做数据采集、网络协议分析、高性能服务端或者单纯想搞懂 .NET 性能底细的开发者。1. 为什么 C# 程序会被 GC 和复制拖慢1.1 托管堆与 GC每个对象都不是免费的很多新手以为 C# 里 new 一个对象很便宜毕竟托管堆内存分配只需要移动一下“分配指针”比 C 的 malloc 还要快。这话只对了一半。真正的成本在回收阶段。垃圾回收器需要跟踪对象的引用、标记存活对象、回收无引用对象必要时还要压缩堆内存。分配得越多GC 需要做的事情就越多。具体到实际场景假设你每秒要处理 20 万条日志每条日志都要截取时间戳、拆字段、转数字。如果每一步都生成新的 string、新的 char[]、新的 byte[]一秒钟可能产生上百万的临时对象。这些对象很快就变成“垃圾”触发频繁的 GC。年轻代还好一旦对象熬过多轮回收升到了老年代或者跑到大对象堆LOH一次完整的 GC 可能带来几十毫秒甚至上百毫秒的停顿。对实时性要求高的系统这就是灾难。我记得有一次调一个上位机通信程序画面周期性卡顿用 dotnet-trace 抓下来一看每秒钟 GC 触发七八次单次 GC 平均 20 毫秒。罪魁祸首就是解析报文时用了大量的 Substring、Split 和临时 byte[]。后来把解析层全部改成 Span 和池化内存GC 次数直接降到每秒不到一次。这个印象太深了。1.2 性能问题的根源数据拷贝和临时对象除了 GC 开销另一个被忽略的杀手是数据拷贝。字符串是不可变的你在 C# 里调用 Substring 截取一段字符得到的不是一个“视图”而是实实在在拷贝了一份新字符串。大数组用 LINQ 的 Skip/Take 再 ToArray也是原地复制整个数据。很多人没意识到一次看似简单的操作背后可能多出了两次内存分配和两次 memcpy。再比如解析二进制协议时经常需要把 byte[] 转成 int、short、float。最粗暴的写法是先用 BitConverter.ToInt32(data, offset)这本质上是按偏移量读取内存不算复制。但如果你先 data.Skip(offset).Take(4).ToArray()再 BitConverter.ToInt32那就白复制了一份。更常见的坑是把字节数组转成 string 再转数字中间产生了 Base64、UTF8 解码、临时 string 等若干中间对象。这些问题的根源在于我们真正想要的往往不是数据的“副本”而是一个可以安全访问的“窗口”或“视图”。不复制数据不产生临时对象只通过一个结构体来描述“哪块内存多长怎么访问”——这就是 Span 和 Memory 诞生的原因。2. Span 核心剖析ref struct 与零拷贝切片2.1 Span 到底是什么ref struct 与栈上约束Span 在 .NET 里被定义为一个 ref struct。这是它最特殊的地方。ref struct 类型的实例只能存活在栈上不能被装箱成 object不能被用作类的字段也不能跨越 await 边界。听起来限制很多但这些限制恰恰是性能和安全的基础。编译器允许 Span 内部持有一个指向托管对象内部的 managed reference也可以指向栈上通过 stackalloc 分配的内存或者指向非托管内存比如 NativeMemory.Alloc 得到的指针。如果没有 ref struct 的栈上约束Span 就能被随意地塞进字段、装进集合、逃逸到堆上那么指向的底层内存可能早就被 GC 移动了或者释放了程序就会产生悬空引用。正因为编译器强制它的生命周期只能局限在栈帧内才能在访问时保证引用的内存仍然有效。举个例子你可以从数组里创建一个 Span然后像操作数组一样索引它甚至做切片。Span 内部存储的是引用、长度和快速索引所需的信息你无法把它保存到数组或类里编译器会在你试图这么做的时候直接报错。对于不了解背景的人来说这些报错很烦人但理解了设计意图后会发现这是防止你写出内存安全问题的护身符。2.2 Slice 与零拷贝切片从数组、字符串、非托管内存上开窗Span 最拿手的操作就是切片。数组有内存区域字符串也是连续的内存区域stackalloc 分配的是栈上连续区域非托管内存更不用说。你可以用统一的方式对这些区域创建 Span然后使用 Slice 方法切出一段。这个过程不会复制任何数据只是构造一个新的 Span指向原区域中的某一段偏移位置。byte[] buffer new byte[1024]; Spanbyte span buffer; Spanbyte header span.Slice(0, 16); Spanbyte payload span.Slice(16, buffer.Length - 16); header[0] 0xAA; // 直接修改原数组的元素同一个数组、不同长度的切片本质上只是“指针长度”的组合完全零拷贝。字符串也是同理string.AsSpan() 可以直接拿到一个 ReadOnlySpan 不用把字符串复制出来。接下来对 Span 做 Substring 式的截取再调用 int.Parse(ReadOnlySpan ) 或者新增的许多 API中间都不会产生临时字符串。还有一类场景是读取二进制数据。很多协议头里有 tag、length、checksum如果手动用指针强转不安全也很容易错用 Span 加 BinaryPrimitives 类会稳妥很多。比如读取 int 字段时可以用 BinaryPrimitives.ReadInt32LittleEndian(span)它内部就是按字节序读取无需额外分配。2.3 使用边界为什么 Span 不能存字段、不能装箱我在实际开发里踩过很多编译错误最典型的是想把 Span 保存到类字段里比如作为解析器的内部状态结果报错“ref struct 不能用作字段”。这逼着我去正面理解它的设计。Span 的生命周期必须比它引用的内存更短。对于 stackalloc 的内存一旦方法返回栈内存就失效了对于数组内存如果 Span 逃逸到了堆上GC 就无法追踪它的引用安全。ref struct 的栈上约束直接锁死了这种逃逸路径。如果你需要在堆上长期持有这块内存或者需要在异步方法里跨 await 处理那就得用 Memory 。另一个常见限制是不能装箱。object obj span;直接编译失败。这是因为装箱会强制把数据放到堆上与 ref struct 的约束冲突。有些人会纠结难道用集合就完全不行吗其实普通 ListSpan 做不到但可以换思路不要存 Span而是存你实际拥有的 Memory 或者存数组和偏移量需要时再临时创建 Span。理解了这一点使用起来就不会再撞墙。3. Memory 、ReadOnlySequence 异步和流式场景的解决方案3.1 Memory 为什么存在Span 不能跨 awaitSpan 是栈上类型自然不能跨 await 存活。但在真实的异步代码里你经常要先把数据读入缓冲区等待网络或者磁盘返回然后再处理。这时候就需要一个可以放在堆上、可以作为方法参数传递、可以被集合持有的内存抽象这就是 Memory 。Memory 本质上描述了一段可以驻留在堆上的内存。它不像 Span 那样严格要求栈上存储所以你可以写一个异步方法接收 Memory 作为参数在里面 await 之后继续使用。想要同步访问时调用 .Span 属性把它转成 Span 然后直接操作。常见模式是外层用 Memory 持有数据内层处理逻辑用 Span既保证异步安全又保留高性能访问。async Task ProcessAsync(Memorybyte memory) { // 异步操作之前可以读 byte first memory.Span[0]; await SomeIoOperationAsync(); // 异步操作之后memory 和它的 Span 都还能用 ReadOnlySpanbyte data memory.Span; // 处理 data }注意这里有个细节Memory .Span 在 await 前后都可以获取但不能在同一个表达式中跨越 await 持有 Span。因为 await 之后编译器会让局部变量失效所以你需要在每个阶段获取新的 Span。这种写法非常自然习惯了之后几乎不会踩坑。3.2 ReadOnlySequence 处理不连续缓冲区网络数据经常是分片到达的每次 read 可能只读到半个消息也可能一次读到好几条消息粘在一起。如果每次都要拼到连续的大数组里免不了拷贝。.NET 里专门有个 ReadOnlySequence 类型表示“一段逻辑上连续、底层可能由多个不连续内存块组成”的缓冲区。它本质上是一个链表结构的视图。System.IO.Pipelines 里的 PipeReader.ReadAsync 返回的就是 ReadOnlySequence 。你从中读取数据、判断消息是否完整、消费一部分然后把剩下的数据留给下一次读取。SequenceReader 类提供了类似 BinaryReader 的方法可以方便地读取 int、long、字符串而不会产生额外分配。这在解析基于帧的协议时能大幅降低 GC 压力。// 这段是伪代码思路来自 PipeReader 循环 while (true) { ReadResult result await reader.ReadAsync(); ReadOnlySequencebyte buffer result.Buffer; if (TryParseMessage(ref buffer, out Message msg)) { // 处理消息 // 消费已解析的数据 reader.AdvanceTo(buffer.Start, buffer.End); } else { reader.AdvanceTo(buffer.Start, buffer.End); } }关键是TryParseMessage 里的读取操作全部走 SequenceReader不需要把多段 buffer 复制成一个大数组。如果你在写网关、通信中间件、任何需要粘包拆包的场景这个组合几乎可以说是标准答案。最开始觉得 API 复杂但用顺后就不会再想回到 byte[] CopyTo 的日子了。3.3 使用 MemoryPool 与 ArrayPool 管理缓冲生命周期Memory 背后真正持有数据的对象是什么答案是 IMemoryOwner 。通过 MemoryPool .Shared 或者 ArrayPool .Shared.Rent 可以租借一块缓冲区用完后归还由池复用避免反复分配新数组。一个常见模式是IMemoryOwnerbyte owner MemoryPoolbyte.Shared.Rent(8192); Memorybyte buffer owner.Memory; try { // 使用 buffer // 可以把它传给异步方法 await Socket.ReceiveAsync(buffer, SocketFlags.None); // 处理 buffer } finally { owner.Dispose(); // 归还到池中 }为什么不用裸数组因为 Memory 可以直接用于异步方法和流。数组当然也能用比如 ArrayPool .Shared 租出来再配合 MemoryExtensions 使用。区别不大但 MemoryPool 是更通用的抽象而且有些底层实现可以通过“有引用计数”的方式更精细地管理生命周期。实际项目中我用 ArrayPool 多一些因为网络流和文件流的 ReadAsync 新重载都支持 Memory ArrayPool 租出来的数组直接转 Memory 就能传进去。核心思想不是选哪个池而是“不重复分配、用完归还”。4. 实战用 Span 优化真实场景4.1 高效字符串切割与数字解析最典型的上位机解析场景从类似CH1;TEMP25.6;HUM60%#\r\n的字符串里提取数值。传统写法用 Split 和 Substring再加上 double.Parse每个字段都会产生垃圾。用 Span 改写的思路是直接对原始字符串的 ReadOnlySpan 进行切片只在最后一个需要转化为 string 的字段才调用 ToString。ReadOnlySpanchar line CH1;TEMP25.6;HUM60%#\r\n; int semicolonIndex line.IndexOf(;); if (semicolonIndex 0) return; // 提取 TEMP25.6 ReadOnlySpanchar tempPart line.Slice(semicolonIndex 1); int eq tempPart.IndexOf(); ReadOnlySpanchar valueSpan tempPart.Slice(eq 1, tempPart.Length - eq - 1); double temp double.Parse(valueSpan, CultureInfo.InvariantCulture); // 零分配这样写valueSpan 指向 line 内部的一段不会创建临时字符串。double.Parse 有接收 ReadOnlySpan 的重载所以不需要 ToString。如果要截取多个字段可以用一个简单的循环配合 IndexOf 和 Slice把所有解析都控制在“对原始字符串切片”的模式下。我实测过一个每秒 10 万条类似日志的解析程序改成这种方式后分配量降低了约 80%处理速度提升了两三倍。代码里有个值得注意的点Index -1 的时候要判断别直接 Slice。另外如果最终确实需要存储字符串字段比如把某个参数名转成 string 保存再调用 span.ToString() 是合理的。不要为了“零分配”牺牲业务逻辑该分配的再分配。4.2 使用 ArrayPool 和 Stream 读写异步 IO 场景中最忌讳每个异步调用都 new byte[8192]。用 ArrayPool .Shared 租一个数组配合 Memory 传给 FileStream.ReadAsync 或 Socket.ReceiveAsync读完立刻归还。这样整个服务在高峰期可能只需要几十个可复用的缓冲区而不是每秒几千个新数组。byte[] rented ArrayPoolbyte.Shared.Rent(8192); try { Memorybyte buffer rented.AsMemory(0, 8192); int read await stream.ReadAsync(buffer); ReadOnlySpanbyte data buffer.Span.Slice(0, read); // 立即处理 data } finally { ArrayPoolbyte.Shared.Return(rented); }这里要注意rented 数组的长度往往大于请求的 8192所以一定要明确切片到真正需要的区间。rented.AsMemory(0, 8192)才能保证传入 ReadAsync 的长度正确。归还数组时如果数组中存了敏感信息可以考虑用Array.Clear或指定clearArray: true参数。多数日志场景不需要但在处理隐私数据时要注意。4.3 组合拳管道解析把 Pipelines、ReadOnlySequence 和 Span 组合起来可以写一个非常干净的协议解析循环。之前提到的 SequenceReader 能读取 int/byte/ReadOnlySequence 片段配合 try/catch 处理未完成的半包。我的经验是先定义协议结构然后通过reader.TryReadLittleEndian(out int packetLength)之类的方法读取长度再判断 Buffer 是否足够不够就返回等下一次数据到达。等数据够了再解析字段。这样整个解析过程不会复制任何分包数据内存占用常年保持平稳。如果你刚接触 Pipelines可以先从最小例子开始把 NetworkStream 包装成一个 PipeReader循环读取。等熟悉了再往里加自己的协议解析。这个学习曲线不算陡但值得投入。5. 用 BenchmarkDotNet 验证性能收益5.1 为什么性能优化不能拍脑袋很多人改代码只靠“感觉”。有一次我觉得用 Span 后程序变快了结果用 BenchmarkDotNet 一测差别几乎可以忽略。原因是我优化的路径根本不是热点。所以高性能优化一定要先测量再动手。BenchmarkDotNet 是最通用的工具在你标了 [Benchmark] 的方法上做预热、多次运行、统计内存分配输出分配量和耗时。一个基础的对比基准可以这样写[MemoryDiagnoser] public class StringParseBenchmark { private string input ID12345;TEMP25.6;HUM60%; [Benchmark(Baseline true)] public double ParseWithSubstring() { string temp input.Substring(input.IndexOf(TEMP) 5); temp temp.Substring(0, temp.IndexOf(;)); return double.Parse(temp, CultureInfo.InvariantCulture); } [Benchmark] public double ParseWithSpan() { ReadOnlySpanchar span input; int idx span.IndexOf(TEMP); span span.Slice(idx 5); int end span.IndexOf(;); return double.Parse(span.Slice(0, end), CultureInfo.InvariantCulture); } }跑出来的结果通常显示Span 版本耗时可能只有 Substring 版本的一半分配从几百字节降到 0。当然实际比例取决于字符串长度、字段数量和解析复杂度。关键是 BenchmarkDotNet 会告诉你 Allocated 那一列到底有多少字节这比嘴上说“零复制”有说服力得多。5.2 何时该用 Span何时不值得不是所有地方都需要 Span。对极短的字符串、频率极低的操作、或者生命周期很短的局部数组引入 Span 带来的代码复杂度可能大于收益。另外如果你需要把 Span 保存到字段、传入 async 方法深处就会陷入各种编译器的束缚反而增加了心智负担。我常用的判断标准是三个问题这个操作是不是热点用 profiler 看是不是每秒千次以上是不是会产生大量临时对象。三个里有至少两个回答“是”就值得改造。否则先别动保持代码可读性。高性能追求的是“够用就好”不是把每一行都改成零分配。还有一个容易被忽略的点Span 版本的可读性。代码最终是给人读的。如果你为了零分配写出一串复杂的 Slice 和 IndexOf不如先封装一个小的 helper 类把切割逻辑封装好再对外开放简单的 API。性能和可维护性可以兼得但需要你额外做一层设计。6. 常见坑与实用排查技巧6.1 Span 的隐藏限制清单我把平时遇到过、看过别人遇到的典型编译错误整理成一个速查表照着检查能少走弯路操作问题把 Span 保存到类字段编译错误ref struct 不能作为字段把 Span 装箱为 object编译错误无法装箱 ref struct在 async 方法里直接用 Span编译错误不能跨越 await 使用 Span把 Span 用作泛型类型实参在旧版 .NET 中不支持C# 13 起可通过 allows ref struct 放宽把 Span 存到 List 或数组编译错误ref struct 无法作为类型参数使用 stackalloc 后返回指向它的 Span未定义行为可能内存访问异常这些限制的底层原因都是生命周期。如果你需要长期存储换 Memory 如果你需要泛型支持尽量限制为少量内置泛型类型。我建议遇到编译报错时先停下来想一分钟“编译器在保护什么”而不是立刻搜 workaround。真正理解了才知道怎么设计绕过。6.2 生命周期问题不要让 Span 指向已释放的内存Span 本身不分配内存它只是内存的“视图”。所以真正要管理的是它引用的内存。数组和字符串由 GC 管理不需要你操心。但 stackalloc 的内存和非托管内存需要格外小心。Spanbyte span; unsafe { byte* ptr (byte*)NativeMemory.Alloc(1024); span new Spanbyte(ptr, 1024); // 此时可以使用 span NativeMemory.Free(ptr); } // 这里再访问 span 就是访问已释放的内存 —— 错误这里如果你仍在某个变量里持有原先的 Span访问它就会出问题。在 C# 里因为编译器会自动判断生命周期通常会提前报错但涉及非托管指针时安全网有时会失效。我的建议是永远不要把非托管内存对应的 Span 存到更外层的变量中用完立刻释放并且尽量缩小 Span 的作用域。排查这类问题可以用 dotnet-trace 抓 GC 信息和异常或者用 NativeMemory 分配后配合 Use-after-free 检测工具。不过更重要的是编程习惯每创建一个指向非托管内存的 Span就在同一个作用域内完成所有操作不要试图把 Span 传出去。6.3 Memory 泄漏与池管理使用 ArrayPool 和 MemoryPool 时一个常见的反模式是忘记 Return。一旦忘记归还池里的缓冲区就会被耗尽然后底层开始不断租用新内存就退化成普通分配而且还会有内存泄漏。所以池化内存一定要用 try/finally 或 using 确保归还。有些代码里出现了一堆异常分支很容易漏掉 return。我习惯用一个小工具方法封装租用和归还逻辑或者直接依赖 C# 的 using 语句。另一个容易被坑的细节是 Memory 的生命周期。如果你把一个 Memory 传给异步方法而异步方法在完成后仍然持有它例如存到了某个队列里那这块缓冲区超出预期地活着池就少了一块可复用内存。所以使用池化内存时要约法三章谁租谁还用完立即还不要进行延迟消费。我在几个项目里推行过“缓冲所有权标记”的简单规则方法接收 Memory 时注释清楚是“只读借用”还是“拥有所有权”。配合 Code Review基本上能杜绝大部分池化内存相关的 bug。7. 写在最后从“会写”到“敢用”的体会做了这么多年 C# 开发我对 Span 和 Memory 的感情有点矛盾刚接触时觉得限制太多、报错太烦真正啃下来后又觉得这是 .NET 给开发者的最好礼物之一。最明显的转变是在做上位机协议解析时把解析层从 string.Split 改成 Span 切片后GC 分配从每分钟几十 MB 降到了几乎为零整个程序的卡顿彻底消失。那一刻我才明白ref struct 的种种束缚其实是用编译期的严格换运行时的自由。如果你打算在团队里引入这套技术我建议从小范围开始。先挑一个明确的性能瓶颈用 BenchmarkDotNet 量化现状再用 Span 改一版。同时抽时间把 MemoryPool、ArrayPool、Pipelines 至少各写一个最小示例跑通流程。不用急着把所有代码都改成零分配先把一两个最痛的点解决掉你就会找到手感。最后分享一个小技巧在 .NET 源码里搜索 Span 的用法比看任何教程都有用。比如 string 里对 Substring 的实现、Stream 里对 ReadAsync 的优化都体现了微软工程师如何处理边界条件和性能取舍。把这些源码当作实战教材比照着自己写能少走很多弯路。高性能内存处理没有银弹但有 Span 和 Memory 这样顺手的好工具值得每个 .NET 开发者好好掌握。