尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

.NET内存管理进阶:深入理解Memory<T>与Span<T>的生命周期与实战指南

.NET内存管理进阶:深入理解Memory<T>与Span<T>的生命周期与实战指南 先把话放在前头这一篇不是入门教程而是给已经在用 .NET 做服务端开发、但觉得内存这块“好像总隔着一层纸”的人看的。我自己第一次认真研究 Memory 是因为线上一个高吞吐的网络报文服务出了问题——内存只涨不降GC 越来越频繁最后发现根因就是缓冲区复制太多、池化内存又没归还。这篇文章要讲的就是 Memory 到底是什么、它和 Span 怎么分工、在真实项目里怎么用、以及那些文档里不会明写但迟早会踩的坑。Length: 返回当前视图的长度Span: 获得当前视图对应的 SpanSlice(int start, int length): 创建更小范围的视图Pin(): 固定内存返回 MemoryHandle用于非托管互操作CopyTo(Span ): 复制到目标 Span这个结构对我们最直接的影响Memory 可以在堆上存在、可以放进 async 状态机、可以被 Lambda 捕获因为它就是一个普普通通的结构体。它像一张“内存地图”地图本身不拥有数据但地图足够轻量随便传递都不会有性能压力。2. 源码视角Memory 到底封装了什么2.1 三个字段撑起一个通用内存视图看 MemoryT 的源码实现会惊讶于它的简单。在 .NET 7 左右的版本里它内部基本上就是三个字段private readonly object? _object; private readonly int _index; private readonly int _length;你没看错就这么点东西。_object用来引用底层数据源可以是数组、字符串、或者实现了IMemoryOwnerT或者MemoryManagerT的对象。_index记录起始偏移量_length记录长度。正是这种设计让 MemoryT 既能包装一段数组、又能包装字符串片段、还能包装非托管内存块而不是像ArraySegmentT那样只局限在数组上。了解这三个字段对理解后面的坑有帮助当你把一个数组拆成两个 MemoryT 时底层还是同一块数组只是_index和_length不同。如果你不小心保存了 MemoryT 的底层数组引用等于绕过了 Memory 的抽象直接抓住了它的“内脏”。2.2 生命周期契约持有者与借用者MemoryT 本身不负责内存的生命周期管理这一点是理解整个体系的关键。它只是个“借用者/视图”。真正负责内存生命周期的是IMemoryOwnerT。看名字就知道谁拥有谁负责释放。在实际使用中生命周期链条是这样走的从MemoryPoolT租借一块内存得到IMemoryOwnerT通过owner.Memory拿到 MemoryT传给消费者消费者处理完成后调用owner.Dispose()把内存归还池子这个契约很像图书馆借书池子是图书馆IMemoryOwnerT是借书证MemoryT 是你正在看的书页。书可以传阅但最终必须有人拿着借书证去还书。如果不还图书馆的书会越来越少最后新读者来了无书可借。2.3 复制 Memory 不等于复制数据很多初学者第一次用 MemoryT 时会产生误解既然它是 struct那我var mem2 mem1是不是就把数据复制了一份答案是完全不是。byte[] array new byte[1024]; Memorybyte mem1 array.AsMemory(0, 512); Memorybyte mem2 mem1; mem2.Span[0] 42; Console.WriteLine(array[0]); // 输出 42复制 MemoryT 只复制了“引用 偏移 长度”这三个值底层数据没有任何拷贝。这样做的好处是传递成本极低内存分配为零性能不会因为频繁传入传出而下降。坏处是如果你不确定谁在写底层数据就可能出现并发写冲突。这不是 MemoryT 的设计缺陷而是它的设计前提——它假定你清楚自己在做什么。这也正是我后来坚持的一个约定在方法签名里如果只读就用ReadOnlyMemoryT如果需要修改就用MemoryT尽量避免裸传MemoryT但内部只读不写的情况。这样看代码的人一眼就知道这个方法的意图不需要去翻实现。3. 实操链路从 ArrayPool 到 MemoryPool 的完整用法3.1 先用 ArrayPool 手动实现“借-还”在引入 MemoryPool 之前很多人已经用ArrayPoolT解决问题了。ArrayPool 的核心思想就是不重复分配新数组而是从池子里借一个数组用完还回去。这在频繁需要大缓冲区的场景下能极大减少 GC 压力。byte[] buffer ArrayPoolbyte.Shared.Rent(1024); try { // 使用 buffer FillData(buffer, out int written); ProcessData(buffer.AsSpan(0, written)); } finally { ArrayPoolbyte.Shared.Return(buffer, clearArray: true); }注意这里两个细节Rent的参数是最小长度返回的数组实际长度可能比请求值大所以不能依赖buffer.Length必须记录实际写入长度。Return的第二个参数clearArray是根据场景定的。如果缓冲区里存过敏感数据比如密钥、token必须清空再归还否则下一个借到这块内存的代码可能读到残留数据。这是安全红线。ArrayPool 的缺点很快会暴露你得自己维护 try/finally一旦代码中间有多个 return 分支或者异常路径很容易漏掉 Return内存就一直在池子里占着表现为程序内存居高不下。而且它返回的是裸数组不是视图所有“借出去多少、实际用多少”都得自己记账。3.2 让 MemoryPool 和 IMemoryOwner 接管MemoryPoolT和IMemoryOwnerT就是在 ArrayPool 之上做了一层更友好的抽象。MemoryPoolT.Shared底层实际上用的就是 ArrayPool 机制但使用体验好很多using IMemoryOwnerbyte owner MemoryPoolbyte.Shared.Rent(1024); Memorybyte memory owner.Memory; // 用完自动归还因为 using 会调用 Disposeusing声明让生命周期管理变得非常简单。只要遵循“谁 Rent 谁负责 Dispose”的原则基本不会出现忘了归还的问题。对比一下 ArrayPool 的手动 try/finallyMemoryPool 的方案不仅代码更短而且因为Memorybyte携带了长度信息你不需要再维护一个变量记录实际写入了多少。3.3 完整案例一个异步数据管道我把这两个抽象放在一个真实场景里演示模拟一个发布-消费的数据管道。这个例子是我实际项目中拆出来的简化版非常能说明 MemoryT 为什么在异步场景下不可替代。public sealed class DataPipeline { private readonly MemoryPoolbyte _pool MemoryPoolbyte.Shared; private readonly Channel(IMemoryOwnerbyte Owner, int Length) _channel Channel.CreateUnbounded(IMemoryOwnerbyte, int)(); // 生产端接收外部数据放入管道 public async ValueTask PublishAsync( ReadOnlyMemorybyte data, CancellationToken ct default) { // 从池中租借一块内存并复制数据 IMemoryOwnerbyte owner _pool.Rent(data.Length); data.CopyTo(owner.Memory); // 把所有权转交给消费端 await _channel.Writer.WriteAsync((owner, data.Length), ct); } // 消费端从管道取出数据处理完后归还 public async Task ConsumeAsync(CancellationToken ct default) { await foreach ((IMemoryOwnerbyte owner, int length) in _channel.Reader.ReadAllAsync(ct)) { using (owner) { ReadOnlySpanbyte data owner.Memory.Span.Slice(0, length); HandlePacket(data); } } } }拆开来看这套代码体现了 MemoryT 体系的完整链路PublishAsync从池中租借内存把外部数据复制进来然后把IMemoryOwnerbyte连同长度一起放进 Channel。消费端从 Channel 取出数据在using中处理处理完自动调用Dispose归还内存。数据通过 Channel 跨越了异步边界而整个过程没有额外分配新的byte[]内存始终来自池子。这里有个经验之谈数据复制还是存在的但复制的目标从“新分配的大数组”变成了“池中已有的缓冲区”GC 的分配量会小很多。如果业务允许甚至可以不复制直接生产端锁定一段内存直到消费端处理完这样连复制都省了——但实现复杂度会高不少我留到后面进阶部分说。关于 Channel 和 MemoryPool 的组合还有一个隐藏风险如果生产速度远快于消费速度Channel 里会堆积大量IMemoryOwner相当于池子里的内存被借走了一大批而没有及时归还整体内存占用会持续上升。所以高级用法一定要配合背压机制比如用有界 Channel 或者在 Publish 时判断_channel.Reader.Count是否超过阈值。这个坑我踩过一次线上表现就是内存曲线稳步上升活儿排得越久涨得越狠。4. 踩坑记录四个我交过学费的地方4.1 在 async 方法里碰 SpanT编译器直接翻脸这是新手最常见的问题。SpT 被设计为 ref struct所以它不能跨域异步方法。你一旦写出下面这种代码public async Taskint HandleAsync(Spanbyte data) { await Task.Delay(10); return data.Length; }编译器会报错ref struct 不能在异步方法中使用。原因很底层异步方法的状态机会把参数保存到堆上而 SpanT 内部带 ref 字段不能存到堆上。所以这个限制不是“可以绕过的”是语言层面强制保证安全的。解决办法就是 MemoryT。但是注意我说“用 MemoryT 替代”并不是说可以肆无忌惮地在异步里访问而是说 MemoryT 可以作为一个跨 await 的载体真正要高效访问数据时还是在方法内部的同步代码块里取 Span 使用。一个最容易犯的错是在拿到 Span 之后还有 await 逻辑public async Task HandleAsync(Memorybyte data) { Spanbyte span data.Span; // 这里如果做 awaitspan 就变得危险 await Task.Delay(10); span[0] 1; // 编译器未必拦得住但语义上已经是悬崖边 }实际上编译器在大多数情况下会尝试阻止这种越过 await 点使用 Span 的情况但你最好从设计上就避免。我的经验是在一个方法里先同步取 Span、同步用数据、同步改完然后再做 await 后续动作。4.2 借出来的内存忘了归还内存只涨不降这个坑经典到我在不同项目里遇到至少三次。症状高度一致服务刚启动时内存正常运行几小时或一两天后工作集稳定上涨GC 做了很多次还回收不了多少最终触发 OutOfMemory 或者频繁 Full GC 拖垮吞吐。根因往往是有一处MemoryPool.Rent没有走using或没有Dispose。你以为借了一块内存处理完就丢了引用实际上池子始终认为这块内存“已借出”别人借不到GC 也不能回收这块内存就永久挂在你进程里。排查方法我整理成了一句话口诀先看 GC 代数再看堆类型最后抓 Byte[] 。用dotnet-counters monitor --process-id pid --counters System.Runtime观察% Time in GC和gen-2 gc count如果 Gen 2 GC 变得频繁大概率有大对象或池化内存没有正常回收。用dotnet-dump collect -p pid抓 dump再用dotnet-dump analyze执行dumpheap -type Byte[]。如果你发现大量 1KB 到 1MB 的 Byte[] 实例数量异常多且它们都被某个模块引用着基本就能顺着引用链找到泄漏点。还有一个更直接的防御手段在开发阶段给池子的“租借”和“归还”加上计数压测时观察两端是否平衡。代码非常简单public sealed class PoolTracker { private long _rentTotal; private long _returnTotal; public IMemoryOwnerbyte Rent(MemoryPoolbyte pool, int minSize) { Interlocked.Increment(ref _rentTotal); return new TrackedOwner(pool.Rent(minSize), this); } internal void OnReturn() Interlocked.Increment(ref _returnTotal); public bool IsBalanced Interlocked.Read(ref _rentTotal) Interlocked.Read(ref _returnTotal); private sealed class TrackedOwner : IMemoryOwnerbyte { private readonly IMemoryOwnerbyte _inner; private readonly PoolTracker _tracker; public TrackedOwner(IMemoryOwnerbyte inner, PoolTracker tracker) { _inner inner; _tracker tracker; } public Memorybyte Memory _inner.Memory; public void Dispose() { _inner.Dispose(); _tracker.OnReturn(); } } }测试结束时检查IsBalanced如果不平衡说明一定有代码路径没有归还。用这个方法我一次就抓到了某个异常分支里漏掉的 Dispose前后省了好几天。4.3 用 Memory.Array 绕过池管理导致内存不可控MemoryT有一个危险属性叫Memory.Array注意我的措辞它是个危险属性。它确实能拿到底层数组但代价是让逻辑绕过了 Memory 的抽象边界尤其当你通过ArrayPool或MemoryPool管理内存时一旦拿到了底层数组并保存了引用整个内存生命周期就失控了。举个典型场景有人写了一个方法从Memorybyte中取出byte[]然后保存到字段里方便后续使用。听起来没什么实际上它打破了“使用时临时借用用完立即归还”的契约。因为外部字段持有数组引用池子无法感知归还后这块内存是否还在被使用下一次Rent把同一块内存借给别人时两个地方同时写数据就错乱了。我的建议不要在普通代码里依赖.Array属性。如果你需要拿到底层结构用MemoryMarshal.TryGetArray并明确判断返回值而且取出来后也只是临时代码不能长期保存。如果确实需要长期持有数据就直接ToArray()复制一份宁可花一次分配换安全性也比埋一个不知道怎么排查的内存故障强。4.4 多个消费者共写同一块缓冲区数据静默错乱MemoryT 是值类型传参时复制成本极低但复制的是“视图”不是“数据”。这就导致一个常见误用多个线程持有了同一个Memorybyte的视图然后各写各的以为彼此隔离实际都在改同一块底层内存。之前我就见过一个线上问题有一个批量处理系统把一个大包拆给 8 个线程并行处理每个线程拿到的是一个Memorybyte切片。理论上切片之间是隔离的但有一个线程错误地对整个包长度做了覆盖写结果把其他线程的数据冲掉了。这类 Bug 的表现不是立刻崩溃而是偶发性的数据错乱极难定位。所以我在这类场景下立了一个规矩并发写同一块池化内存时必须约定清楚每个线程写哪个区段要么用独立的切片要么加锁。特别注意池化内存归还时不要只归还部分切片要归还当初从池子里租出来的那个完整的IMemoryOwner否则整个池子的状态就乱了。5. 进阶玩法用 MemoryManager 接管非托管内存5.1 什么时候才需要自己实现 MemoryManager大多数开发者的需求用MemoryPoolT就够了但有一种场景必须要MemoryManagerT出马当数据源不在托管堆里而是来自非托管内存段或者内存映射文件时你想用统一的MemoryT视图去操作它又不想经历Marshal.Copy把内存搬到托管堆。我自己遇到过的一个场景是和 C 库做互操作。C 那边返回了一块连续内存里面是结构体数组用托管代码复制一份没问题但如果数据很大而且每次调用都复制性能损耗就相当可观。这时如果能把这块内存包装成MemoryT传给上层 C# 代码直接读效率和代码整洁度都会好很多。5.2 用 MemoryManager 包装非托管内存public unsafe sealed class UnmanagedMemoryManagerT : MemoryManagerT where T : unmanaged { private readonly T* _pointer; private readonly int _length; private bool _disposed; public UnmanagedMemoryManager(T* pointer, int length) { _pointer pointer; _length length; } public override SpanT GetSpan() { ThrowIfDisposed(); return new SpanT(_pointer, _length); } public override MemoryHandle Pin(int elementIndex 0) { ThrowIfDisposed(); return new MemoryHandle(_pointer elementIndex); } public override void Unpin() { // 非托管内存不需要真正的 pin/unpin所以这里不需要做什么 } protected override void Dispose(bool disposing) { _disposed true; } private void ThrowIfDisposed() { if (_disposed) { throw new ObjectDisposedException(nameof(UnmanagedMemoryManagerT)); } } }使用方式// 假设 ptr 来自 Marshal.AllocHGlobal 或 NativeMemory.Alloc byte* buffer (byte*)Marshal.AllocHGlobal(4096).ToPointer(); try { using var manager new UnmanagedMemoryManagerbyte(buffer, 4096); Memorybyte memory manager.Memory; for (int i 0; i memory.Length; i) { memory.Span[i] (byte)i; } } finally { Marshal.FreeHGlobal((IntPtr)buffer); }这段代码的关键点是Dispose只标记了“这个 MemoryT 视图已失效”它不会释放非托管内存。非托管内存的释放仍然由你手动控制。这是很多人最容易误解的地方——MemoryManagerT是“管理器”不是“所有者”。使用中还有一个必须注意的安全问题如果你使用Pin()拿到了指针期间 GC 不会移动这块内存但是你自己要保证在这段时间内没有别的线程调用Dispose。跨线程使用MemoryManager时最好在调用入口处做一次状态检查。另外想提一句项目里如果大量用非托管内存建议做好“谁申请、谁释放”的注释别指望着后面的人仅凭代码就能搞清楚所有权。经验不足的维护者很可能在Dispose后还继续访问Memory这种 Bug 在发布版本里极难复现只能靠 code review 和测试来堵。5.3 生态联动与兼容性提示MemoryT 并不是孤立存在的。你在实际项目中会发现它和这些类型深度绑定ReadOnlySequenceT用于Pipe读写在 ASP.NET Core 管线、System.IO.Pipelines中大量出现。它内部就是由多个ReadOnlyMemoryT切片组成的链表。PipeReader/PipeWriter高性能 IO 管道的核心抽象读取到的数据就是ReadOnlySequencebyte底层大多来自池化内存。ChannelT可以放任意类型但和IMemoryOwnerT搭配时一定要考虑消费者的释放时机我在前面的案例里已经演示过。兼容性方面MemoryT 是在 .NET Core 2.1 时代引入的属于 .NET Standard 2.1。如果你还在维护 .NET Framework 老项目想在老框架上使用这些类型必须引入System.MemoryNuGet 包。另外要注意SpanT和MemoryT在 .NET Framework 4.5 以上虽然可以通过这个包使用但 JIT 层面的优化不如 .NET Core/5 彻底性能收益会打折扣。如果你正好在升级老项目我的建议是先小范围用ArrayPoolT替换明显的大数组分配验证性能收益之后再往MemoryPoolT迁移。一次性大面积重构永远是风险最高的选择。还有一个生态相关的重点在System.Text.Json、System.Text.Encodings.Web、Stream等 BCL API 中MemoryT 相关的重载已经非常普及。比如Stream.ReadAsync(Memorybyte)、Stream.WriteAsync(ReadOnlyMemorybyte)在 .NET Core 3.0 就是默认推荐用法。如果你还在用byte[]版本说明你的代码还停留在旧时代值得花点时间升级。把我个人这几年用下来的体会做个总结式收尾吧但我不想用“总之”这种话只想说一个最实在的约定现在我写任何和缓冲区相关的代码时默认规则是——同步逻辑用SpanT异步边界用MemoryT对外只读数据用ReadOnlyMemoryT需要池化的内存一律走MemoryPool并以IMemoryOwner管理生命周期。这套规则帮我避开了绝大多数内存问题。最后再分享一个小技巧写单元测试时不要只测“功能正确”要把“借用/归还平衡”也当做一个断言条件来测。如果有一天你在 review 代码时看到别人用了MemoryPool但没在using块里处理我劝你先别急着说是小问题——这种细节在压力测试时会被放大无数倍成为线上事故的导火索。MemoryT 这套体系本身很优雅但优雅的前提是所有人都尊重它的生命周期契约。
返回列表