说实话,我第一次接触Memory<T>是在处理一个高频网络网关项目时。当时服务每秒钟要解析上千个报文,每个报文都要从byte[]里反复切片、拷贝、再传给异步方法继续处理。明明数据量不算大,GC 压力却高得吓人,CPU 时间全耗在内存复制和对象分配上。后来我把核心链路全改成Memory<T>,一次性解决了两个问题:因为内存可以原地复用,分配次数直线下降;因为切片不再产生新数组,热点路径的性能一下就稳住了。这篇文章就围绕这个类型,把我踩过的坑、验证过的实践,以及它和Span<T>之间的“爱恨情仇”一次讲透。无论你是刚接触 .NET 内存管理的中级开发者,还是正在做高性能服务的架构师,这篇文章都值得你花十分钟读完。
1. 为什么需要 Memory<T>:Span<T> 的“先天限制”是所有故事的起点
1.1 Span<T> 什么都好,却碰不了 async/await
做过高性能 .NET 开发的人,几乎都绕不开Span<T>。它能在不产生任何堆分配的前提下,对连续内存做结构化视图,切片、遍历、搜索都非常快。但Span<T>本身是 ref struct,这个身份带来了一个硬约束:它只能在栈上存在,不能装箱,不能成为类的字段,更不能跨越await边界存活。
await本质上会把当前方法的状态保存到堆上的状态机里,而 ref struct 不允许出现在堆对象中,所以你在async方法里一用await,编译器就会直接报错,告诉你“不能在这里使用 Span”。我记得第一次写代码时遇到这个报错,整个人是懵的:明明Span<T>性能那么好,怎么一遇异步就废掉?
如果你只是写一些同步算法,Span<T>足够用了。可现实里大部分 I/O 密集应用都离不开异步。一个网络请求进来,从 Buffer 里读取数据、解析、转发、写回,每一步都可能要await。在那条链路上,Span<T>基本没有立足之地。这就是Memory<T>登场的理由:它是Span<T>的“堆安全版本”,不限定在栈上,可以放进类、放进结构体、放进状态机,自然也能跨越await。
1.2 把 Memory<T>理解为“有生命周期的指针+长度”
很多初学者把Memory<T>当成高级数组,这么理解不算错,但会错过它真正的设计意图。Memory<T>本质上描述了一段连续内存的“视图”,它内部持有对底层对象的引用、起始偏移量和长度。你可以把它看作一个“带边界检查的指针”:它不拥有内存,只是告诉别人“从这里开始的 N 个元素是你可以用的”。
正因为它是引用对象(不是 ref struct),所以它被复制时非常便宜,几十个字节而已。切片时也不产生新数组,只是创建了一个新的偏移量和长度组合。这个特性让Memory<T>成为 async 环境中零拷贝切片的最佳载体。
这里要提醒一个关键点:Memory<T>和Span<T>一样,本身不负责内存的分配和释放。真正负责内存生命周期的是底层对象,可能是T[],可能是string,也可能是IMemoryOwner<T>。理解这个关系之后,你就明白为什么 .NET 引入MemoryPool<T>和IMemoryOwner<T>来配合Memory<T>使用,后面我会专门讲。
1.3 传统数组和 List<T> 为什么在高并发场景不够用
在 .NET 早期,开发者处理连续内存只有两个选择:T[]和List<T>。它们的问题在压力测试下非常明显。第一,切片必然产生新数组,每个切片都是一次 O(n) 拷贝,高频调用时 GC 压力陡增。第二,List<T>的扩容机制在容量不够时会倍增式拷贝,偶尔一次还能接受,在高吞吐场景下就会制造大量垃圾对象。第三,数组本身没有“视图”概念,你往往得同时传 array、offset、count 三个参数,代码既丑陋又容易出错。
Memory<T>把这些历史包袱一次性扔掉。切片零拷贝,传递只传一个结构体,数据读写在原地上进行,性能自然提升。我见过一个朋友的项目,从传 offset/count 的老写法迁移到Memory<T>之后,同样规模请求下 P99 延迟降低了接近三成,主要省下来的全是 GC 暂停和内存拷贝的时间。
2. 核心细节解析:Memory<T> 的成员、底层类型与那些容易被忽略的陷阱
2.1 三种底层内存来源,行为差异巨大
Memory<T>的底层来源主要有三种:数组、字符串、原生内存。不同来源的实例,在 API 行为上有一些细微差别,平时不注意很容易踩坑。
第一种是数组。new Memory<int>(array)得到的就是对数组的包装,可以直接用MemoryMarshal.TryGetArray把它还原成ArraySegment<T>。还有个细节,数组切片后的Memory<T>依然指向同一个底层数组,只是偏移和长度变了,所以修改会同步反映到原数组。
第二种是字符串。string的AsMemory()扩展方法返回的是ReadOnlyMemory<char>,因为字符串不可变。很多人忽视了这个类型,其实在处理文本解析、日志分析时特别好用,可以避免大量Substring造成的分配。
第三种是原生内存。NativeMemory.Alloc或MemoryManager<T>的某些实现可以产生Memory<T>,但这是高级玩法,需要你自己负责释放,一旦搞错就是内存泄漏或非法访问。我建议没充分理解底层机制之前,不要轻易碰这条路,先从数组走起。
2.2 ReadOnlyMemory<T> 与 Memory<T> 的选择哲学
Memory<T>提供读写能力,ReadOnlyMemory<T>只提供读能力。这个区分不只是 API 设计洁癖,它是一种契约,告诉调用方“这内存你不能动”。
实际开发中,我的习惯是:对外暴露的接口一律用ReadOnlyMemory<T>,内部处理时才用Memory<T>。比如一个解析器,输入数据只需要读取,那参数就定义成ReadOnlyMemory<byte>。这能有效阻止下游代码意外修改共享缓冲区,尤其多线程并发时,写错数据是最难排查的问题之一。使用ReadOnlyMemory<T>之后,编译器在类型层面就把这种错误挡住了。
有人会问,那我何不干脆全用ReadOnlyMemory<T>?答案是你自己需要写的时候还得转成Memory<T>,而Memory<T>到ReadOnlyMemory<T>是隐式转换,反过来没有。所以接口层面收窄权限,实现内部按需放开,是平衡灵活性和安全性的最佳折中。
2.3 一个常见误区:Memory<T> 不等于不分配
我得纠正一个流传很广的说法:用了Memory<T>就不用注意 GC 了。这是误解。Memory<T>本身是结构体,创建它不产生堆分配;但如果底层内存来自new byte[],数组本身的分配和回收依然存在。切片不分配,不代表整个链路不分配。
真正想要减少分配,必须把“内存从哪来”想清楚。如果是每次调用都新建数组再包一层Memory<T>,那 GC 压力一点没少,还多了结构体复制的开销(虽然很小),结果可能比直接用数组更差。这点后面我会用代码实例说明怎么通过MemoryPool<T>复用内存。
2.4 空 Memory 和 default 的区别,别等到了线上才崩溃
Memory<T>有个很容易踩的坑:default(Memory<T>)和Memory<T>.Empty是两个不同的东西。前者表示“没有关联任何内存”,调用它的.Length属性会抛异常;后者是一个有效的空视图,长度为 0,可以安全读取。
我在代码评审时见过不少同事在这上面翻车。比如某个方法返回Memory<byte>,失败时直接return default;,调用方拿着这个值去调Length或者切片,直接抛出InvalidOperationException。处理方式其实很简单:凡是“空结果”,尽量返回Memory<T>.Empty;凡是“失败结果”,才用 nullable 包装,让类型系统帮你把错误显性化。
3. 实操过程:把 Memory<T> 真正嵌入你的异步处理链路
3.1 场景设计:一个模拟的报文处理服务
为了演示这套体系,我们设计一个典型场景:假设有一个网络服务,不断收到二进制报文,每个报文需要先解析头部,再把负载部分交给一组异步处理器去分析。
传统的写法会这样:收到byte[],解析头部时用BinaryReader或者直接数组索引,要传给异步方法时,为了避免后续数据被覆盖,通常会把负载段byte[] payload = new byte[len]; Array.Copy(data, offset, payload, 0, len);再传给下游。每次请求多一次 O(n) 拷贝,高并发下 GC 压力就上来了。
用Memory<T>改写后,核心链路变成了这样:
public async ValueTask ProcessAsync(ReadOnlyMemory<byte> packet, CancellationToken ct) { Header header = Header.Parse(packet); ReadOnlyMemory<byte> payload = packet.Slice(Header.Size, header.PayloadLength); await HandlePayloadAsync(payload, ct); }你注意看,packet.Slice没有拷贝任何字节,只是产生了一个新的ReadOnlyMemory<byte>。而HandlePayloadAsync接收参数后,可以继续在这个视图上做定位、切分,全程零额外分配。
有人担心Memory<T>切片之后,底层数组被回收的问题。这就要讲到Memory<T>最重要的机制之一:切片对象会持有原Memory<T>内部对底层对象的引用。所以只要切片没被 GC 回收,底层数组就不会被回收。生命周期是安全的。
3.2 用 MemoryPool<T> 真正把内存“循环利用”起来
要追求极致性能,还得解决底层数组频繁分配的问题。这时候MemoryPool<T>登场了。它本质上是一个内存池,Rent出来的数组用完后还回去,供后续请求复用,从而减少 GC 压力。
IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(4096); try { Memory<byte> buffer = owner.Memory; // 这里使用 buffer 做读写 } finally { owner.Dispose(); }Rent(4096)并不保证返回的 Memory 正好 4096 字节,可能会更大,所以使用前一定要通过buffer.Length而不是外部记录的长度来判断可用空间。我看过有人直接buffer.Slice(0, 4096),结果原内存长度不够,直接抛异常。这就是对池化行为不熟悉导致的问题。
MemoryPool<T>的共享实例内部对数组做了分桶管理,不同长度区间对应不同大小的底层数组。频繁租还相同大小的内存,命中率很高,分配次数会显著下降。在我那个网关项目里,启用池化后,每分钟的 GC 次数直接少了一个数量级。
有个细节必须提醒:租出来的IMemoryOwner<T>一定要及时释放,否则池子里的内存只进不出,物理内存占用会慢慢上涨。我习惯的做法是“谁租用谁释放”,集中在finally里处理,绝不跨层传递IMemoryOwner<T>,只传递Memory<T>。这样责任边界清晰,不容易泄漏。
3.3 使用 MemoryManager<T> 做自定义内存的“壳”
如果你需要让某个自定义内存块也能变成Memory<T>,比如从非托管堆分配的内存,或者从某个已有的缓冲区管理器借来的内存,MemoryManager<T>就是官方留给你的扩展点。实现它需要提供GetSpan、Pin等方法,代码略繁琐,但它是让自定义内存对象接入Memory<T>统一生态的唯一途径。
我实际项目中很少直接继承MemoryManager<T>,大多数场景用MemoryPool<T>就够了。真正需要自己写的时候,往往是封装某个自己实现的环形缓冲区或共享内存区域。此时我建议先写清晰的内存所有权文档,再动手实现,因为Pin和引用计数的正确性很难靠直觉保证。
3.4 与老 API 的互操作:从 Memory<T> 到数组、到 Stream、到 Span
实际项目里,Memory<T>不太可能从第一行代码用到最后一行,你总得跟老的 API 打交道。我总结了一套常用的互操作方式:
Memory<T>转数组:memory.ToArray(),会产生一份拷贝,适合与旧接口对接。Memory<T>转Span<T>:memory.Span,同步应用内最推荐,因为零拷贝。Memory<T>转Stream:用ReadOnlyMemoryStream之类的包装类,把Memory<T>包装成Stream供流式 API 消费。Memory<T>还原为数组段:MemoryMarshal.TryGetArray(memory, out ArraySegment<T> segment),如果底层确实是数组就能成功,否则返回 false。
这里要特别注意,MemoryMarshal.TryGetArray依赖底层是T[]这个事实,如果来源是MemoryManager<T>或原生内存,就会返回 false。判断成功后再使用,不要假设一定成功。
3.5 使用ReadOnlySequence<T>的场景区分
顺带提一下,Memory<T>描述的是连续内存,但很多协议解析(比如 HTTP/2、gRPC)面临的是跨缓冲区的非连续数据。这种场景官方推荐的是ReadOnlySequence<T>,它可以由多个ReadOnlyMemory<T>片段组成链表结构,配合SequenceReader<T>做线性解析。
我用过一个真实案例:一个 HTTP 解析器,收到的网络包被拆分成了三段,分别位于三个不同的缓冲区块里。如果用Memory<T>硬接,需要先把三段拼成一个连续缓冲区,费时费力。用ReadOnlySequence<byte>则可以直接把多个Memory<byte>串起来,按顺序消费,完全避免拼接。
如果你的场景只需要处理单块连续数据,用Memory<T>就够了,别为了“高级”硬上ReadOnlySequence<T>,那玩意儿对初学者太容易踩坑。
4. 常见问题与排查技巧实录
4.1 异步回调后仍然引用旧 Memory 导致数据错误
这是最经典的内存安全问题。假设某个服务从池子里租了一块内存,把Memory<T>传给了异步方法,异步方法里还没处理完,外层就把这块内存归还池子,接着另一个请求又租到了同一块底层数组。前一个请求的数据就被覆盖了。
解决办法只有一个:明确“谁来保证生命周期”。我定的铁律是,IMemoryOwner<T>的创建者负责Dispose,并且只有在下游所有使用该Memory<T>的异步操作全部完成之后才能释放。具体到代码层面,经常用引用计数或者显式等待所有消费者完成。
排查这种 bug 时,现象很迷惑:数据偶尔错乱、值偶尔被改、只在高峰期出现。我建议在开发环境开启取池计数和租用时间戳,必要时直接禁用池化跑一遍,如果问题消失,基本就能确认是生命周期被破坏。
4.2 结构体里直接存放 Memory<T> 的误用
有些人为了“方便”,在实体类里直接存Memory<T>字段,而不是存byte[]。这个做法本身没错,但如果这个实体类要序列化、反序列化,或者要传给其他不感知Memory<T>的组件,就得格外小心。
比如用JsonSerializer序列化一个包含Memory<byte>的对象,默认行为很可能不是你期望的,你得自定义转换器。再比如 EF Core 之类 ORM 根本不认识Memory<T>,会直接抛异常。这类问题一般在架构评审阶段就该发现,可现实中大家往往是在写业务代码时才被绊倒。
我的建议是:Memory<T>只应该出现在“短生命周期、高吞吐”的边界层,比如网络接收、管道处理、解析中间件;不应该渗透到实体建模、持久化层。如果既要高性能又要可序列化,那就定义 DTO 时用byte[],在热点路径再转成Memory<T>使用,做完就丢。
4.3 对池化的 Memory 做 Span 操作后长期持有 Span
Memory<T>.Span拿到的Span<T>有严格的使用期限,只能在该同步代码块内使用,不能逃逸出去保存,更不能在异步方法里继续使用。如果你有跨异步保存切片的需求,请保存Memory<T>而不是Span<T>,这也是我在 1.1 里强调过的核心约束的延伸。
我看到一个挺常见的反模式:用Memory<T>通过池子拿到区块,然后在某个类里把Span<byte>存成字段,美其名曰预分配缓冲区。这种方法在刚测试时可能没问题,一旦代码路径变化、类实例被提升为堆对象,编译器就会直接报错,甚至运行时出现严重错误。
4.4 问题速查表
| 症状 | 可能原因 | 解决思路 |
|---|---|---|
| 异步方法里无法用 Span | ref struct 不能跨越 await | 改用 Memory 或先同步处理完 |
| 数据内容被莫名覆盖 | 池化内存被提前释放,新请求复用了同一块数组 | 把 IMemoryOwner 生命周期延长到所有消费者结束 |
| 释放后访问内存抛异常 | 把 owner.Dispose 放到了内存使用之前 | 检查 finally 块,确保释放顺序 |
| default Memory 访问 Length 报错 | 把 default 当成空视图使用 | 使用 Memory .Empty 表达“空” |
| 字符串 AsMemory 后想修改字符 | 字符串不可变,得到的是 ReadOnlyMemory | 使用 StringBuilder 或 char[] 再包 Memory |
| 池化后内存长度比预期大 | 内存池按桶分配,Rent 返回不一定精确长度 | 使用 buffer.Length 判断实际容量 |
4.5 性能对比:一把数据看清收益
我在一个 4 核 8 线程的测试机上,用 BenchmarkDotNet 跑过一个简单的基准:对 1MB 的byte[]做 1000 次切片操作,分别用数组拷贝和Memory<T>切片,然后测量分配内存和平均耗时。结果很直观:Memory<T>切片平均耗时是数组拷贝的 1/8 左右,分配内存几乎为零。这组数据虽然不能代表所有场景,但足够说明问题——只要链路中切片次数越多、切片数据越大,Memory<T>的优势就越明显。
但我也要强调,它的优势并不绝对。如果每次只切十几个字节、而且切片次数很少,用Memory<T>结构体本身的复制开销可能超过Array.Copy的开销。这种场景我建议直接保持简单,别为了一点理论性能增加代码复杂度。
5. 经验心得:什么场景真正该用 Memory<T>,什么场景别硬上
写了这么多代码,最后还是回到“选择”本身。Memory<T>不是一个“更好的数组”,而是一套内存管理方案。它真正适合的场景至少满足以下几条中的一条:一,链路中存在大量切片,并且切片后的数据需要传给异步方法;二,request 处理是高频短任务,对 GC 停顿敏感;三,你需要将同一块缓冲区在多段处理流程中传递,却不想反复分配新对象。
反过来,如果你的服务 QPS 不高、异步深度很浅、对象生命周期清晰且短暂,直接使用byte[]完全没问题。我见过不少团队为了炫技硬上Memory<T>和池化,最后换来一堆难查的生命周期问题,得不偿失。技术选型的第一原则永远是“匹配真实瓶颈”,而不是“匹配技术潮流”。
还有个特别容易被忽略的点:Memory<T>对代码可读性的影响是双面的。好处是它把 offset/count 封装成一个变量,少了很多“前面传 offset,后面传 count,结果传反了”的低级错误;坏处是初学者看到ReadOnlyMemory<byte>这种长类型会心里发怵,成员方法又多,上手成本明显高于普通数组。所以团队引入它时,最好有一个人把最佳实践写成团队规范,而不是让每个人自己悟。
最后分享一个调试小技巧:在排查与Memory<T>相关的内存问题时,可以临时把MemoryPool<T>.Shared换成一个自己实现的“计数池”,打印每次 Rent 和 Dispose 发生时所在调用栈。你会很快定位到哪条路径泄漏了内存,哪条路径提前释放了内存。这种工具代码不复杂,却能省下几十个小时的困惑时间。
掌握Memory<T>的过程,本质上是一次对 .NET 内存模型认识的升级。当你开始思考“数据从哪里来,由谁负责释放,如何避免无意义拷贝”时,很多东西都会自然串联起来,比如Span<T>、ReadOnlySequence<T>、MemoryManager<T>、池化思想。这些概念彼此咬合,构成了现代 .NET 高性能编程的基石。深入理解它们,比记住一堆 API 签名有用得多。