Memory<T>实战指南:突破Span<T>异步限制,降低GC压力
2026/9/17 15:34:53 网站建设 项目流程

说实话,我第一次接触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>依然指向同一个底层数组,只是偏移和长度变了,所以修改会同步反映到原数组。

第二种是字符串。stringAsMemory()扩展方法返回的是ReadOnlyMemory<char>,因为字符串不可变。很多人忽视了这个类型,其实在处理文本解析、日志分析时特别好用,可以避免大量Substring造成的分配。

第三种是原生内存。NativeMemory.AllocMemoryManager<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>就是官方留给你的扩展点。实现它需要提供GetSpanPin等方法,代码略繁琐,但它是让自定义内存对象接入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 问题速查表

症状可能原因解决思路
异步方法里无法用 Spanref 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 签名有用得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询