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