C#.NET Memory 深入解析:跨异步边界的内存视图与高性能实战
如果说 很多人学完 答案基本都指向同一个类型: 一句话先说透: 如果你把 先看一个典型问题: 这类代码之所以不成立,不是因为缓冲区写法不优雅,而是因为: 这时就需要一种“能表示连续内存,又能安全地跨异步边界传递”的类型。 这就是 你可以先用一句最直白的话理解: 和 但和 对应的只读版本是: 所以你可以把这两个类型先记成: 这是最核心的一组关系。 可以先记这张表: 一个非常实用的理解方式是: 所以实际代码里经常是这样: 也就是说: 概念上, 你可以把它想象成: 它本质上仍然是视图,不是所有权本体。 这点非常重要,因为它意味着: 这是最常见的用法。 这里: 字符串是不可变的,所以这里通常是: 和 这在高性能场景里非常常见。 这里的关键点不是语法,而是: 和 这是 很多代码的模式本质上都是: 这是最值得记住的一句话。 例如异步读取: 这里用 但如果你在同步代码里真正要处理数据: 就更适合直接用 所以你会经常看到一种组合方式: 这正是 因为它不是 更直白地说: 但这里要注意一件非常重要的事: 例如下面这种写法仍然是不对的: 原因是: 正确思路是: 例如: 这里 因为它可以安全地活在堆上。 例如: 这在 这也是 这是一个很容易忽略,但非常关键的点。 例如: 这里真正拥有数据的是: 不是: 同理,如果你用的是内存池: 真正负责生命周期的是: 不是: 所以一旦 因为 它们组合起来非常适合这些场景: 典型写法: 这段代码的价值在于: 如果你的 API 本来就不希望调用方修改数据,那么应该优先考虑: 例如: 这个设计表达得更清楚: 这是 例如: 这些 API 本身就天然适合 比如: 这时 这在高性能系统里非常常见。 这是一个很实际的问题。 适合: 它也是“数组的一段视图”,比 但它的问题是: 适合: 所以今天的新代码里,如果场景允许, 前面已经说过,这是最常见的问题之一。 记住: 如果 它只是视图,不负责释放底层资源。 如果不需要写权限,就优先用 如果根本不需要跨异步边界,也不需要做字段,很多时候直接用 如果你准备在项目里正确使用 你可以这样理解它: 如果你在做这些事情: 那 简介
Span<T> 是 .NET 高性能内存体系里最亮眼的类型,那么 Memory<T> 就是它最重要的搭档。Span<T> 后,马上会遇到几个现实问题:Span<T> 不能做类字段?Span<T> 不能跨 await?IO 场景里,很多 API 更喜欢 Memory<T> / ReadOnlyMemory<T>?Memory<T>Span<T> 更适合同步、短生命周期、高性能内存操作;Memory<T> 更适合需要跨异步边界、跨组件传递、或需要长期持有的内存视图。Span<T> 理解成“只能活在当前同步作用域里的高性能视图”,那 Memory<T> 就可以理解成:更适合活在堆上、可以跨
await 传递的内存视图。为什么需要
Memory<T>?async Task ReadAsync(Stream stream)
{
Span<byte> buffer = stackalloc byte[1024]; // 这里就不行
await stream.ReadAsync(buffer);
}Span<T> 是 ref structawait 可能让方法状态机和局部变量提升到堆上Memory<T> 诞生的核心动机。Memory<T> 到底是什么?Memory<T> 是一段连续内存的可持久视图。Span<T> 一样,它通常也不拥有底层数据本身,而是“指向”一段已有的连续内存。Span<T> 不同的是,它没有 ref struct 的那层严格限制,因此:await 使用。ReadOnlyMemory<T>类型 定位 Span<T>同步短生命周期高性能视图 Memory<T>可跨异步、可长期持有的内存视图 Memory<T> 和 Span<T> 到底是什么关系?类型 是否 ref struct能否跨 await能否做字段 典型用途 Span<T>是 否 否 同步高性能处理 ReadOnlySpan<T>是 否 否 同步只读切片 Memory<T>否 是 是 异步或长期持有 ReadOnlyMemory<T>否 是 是 异步只读视图 Memory<T> 负责“持有和传递”Span<T> 负责“实际操作”Memory<byte> memory = new byte[1024];
Span<byte> span = memory.Span;Memory<T> 表达这段内存可以跨作用域存活;.Span 拿到高性能操作视图。Memory<T> 的本质长什么样?Memory<T> 也可以理解成:(object/reference, index, length)Memory<T> 自己不负责发明一块新内存;最常见的创建方式
1. 从数组创建
byte[] bytes = new byte[1024];
Memory<byte> memory1 = bytes;
Memory<byte> memory2 = bytes.AsMemory();
Memory<byte> memory3 = bytes.AsMemory(100, 200);memory1 指向整个数组;memory3 指向数组中 [100..300) 那一段;2. 从字符串创建只读内存视图
ReadOnlyMemory<char> memory = "Hello".AsMemory();
ReadOnlyMemory<char> world = "Hello World".AsMemory(6, 5);ReadOnlySpan<char> 一样,它适合只读场景,但比 ReadOnlySpan<char> 更适合跨异步边界或长期存放。3. 从
MemoryPool<T> 获取using System.Buffers;
using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(4096);
Memory<byte> buffer = owner.Memory.Slice(0, 4096);MemoryPool<T> 提供可复用内存块;IMemoryOwner<T> 表示这块内存的所有者;owner.Memory 给你的是对应的 Memory<T> 视图;owner,否则这块内存不能正确归还。常见操作
切片
SliceMemory<byte> buffer = new byte[100];
Memory<byte> body = buffer.Slice(10, 20);Span<T> 一样:Slice 只是创建新的视图;获取
Span<T>Memory<byte> memory = new byte[10];
Span<byte> span = memory.Span;
span[0] = 42;Memory<T> 最核心的桥梁。Memory<T> -> .Span -> 真正操作只读转换
Memory<byte> writable = new byte[16];
ReadOnlyMemory<byte> readOnly = writable;Memory<T> 可以很自然地转成只读版本。一个最重要的结论:传递用
Memory<T>,处理用 Span<T>async Task<int> ReadDataAsync(Stream stream, Memory<byte> buffer)
{
return await stream.ReadAsync(buffer);
}Memory<byte> 是因为:awaitstatic void ProcessBuffer(Span<byte> buffer)
{
buffer[0] = 1;
}Span<byte>。async Task HandleAsync(Stream stream, Memory<byte> buffer)
{
int bytesRead = await stream.ReadAsync(buffer);
ProcessBuffer(buffer.Span[..bytesRead]);
}Memory<T> 和 Span<T> 的典型协作方式。为什么
Memory<T> 能跨 await?ref struct。Memory<T> 本身可以安全地存在堆上;能跨
await 的是 Memory<T>,不是 Memory<T>.Span。async Task BadAsync(Memory<byte> memory)
{
Span<byte> span = memory.Span;
await Task.Delay(1);
span[0] = 1; // 不应该这样写
}span 依旧是 Span<byte>Span<byte> 依旧不能跨 awaitMemory<T>.Spanasync Task GoodAsync(Memory<byte> memory)
{
await Task.Delay(1);
memory.Span[0] = 1;
}.Span 的使用没有跨过 await,因此是安全的。为什么
Memory<T> 能做字段?public sealed class BufferHolder
{
public Memory<byte> Buffer { get; }
public BufferHolder(byte[] bytes)
{
Buffer = bytes;
}
}Span<T> 里是不成立的。Memory<T> 最典型的适用场景之一:Memory<T> 不等于“拥有内存”Memory<T> 只是视图,不是所有者。byte[] bytes = new byte[100];
Memory<byte> memory = bytes.AsMemory(10, 20);bytesmemoryusing IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(1024);
Memory<byte> memory = owner.Memory;ownermemoryowner.Dispose(),你就不应该再继续使用这段 Memory<byte>。MemoryPool<T> 和 IMemoryOwner<T> 为什么经常和 Memory<T> 一起出现?Memory<T> 解决的是“视图表达”,而 MemoryPool<T> 解决的是“底层内存复用”。GC 压力。using System.Buffers;
async Task<int> ReadOnceAsync(Stream stream)
{
using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(4096);
Memory<byte> buffer = owner.Memory.Slice(0, 4096);
int bytesRead = await stream.ReadAsync(buffer);
Process(buffer.Span[..bytesRead]);
return bytesRead;
}
static void Process(ReadOnlySpan<byte> data)
{
// 同步处理
}MemoryPool<T> 避免反复分配新数组;Memory<T> 安全地跨异步传递;.Span 在同步处理阶段保持高性能。ReadOnlyMemory<T> 的定位ReadOnlyMemory<T>public sealed class Message
{
public ReadOnlyMemory<byte> Payload { get; }
public Message(ReadOnlyMemory<byte> payload)
{
Payload = payload;
}
}典型使用场景
1. 异步
IOMemory<T> 最典型的主场。Stream.ReadAsync(Memory<byte>)Socket.ReceiveAsync(Memory<byte>)PipeReader / PipelinesMemory<T>。2. 需要长期持有某段缓冲区
3. 只想暴露一段视图,不想暴露整个数组
Memory<T> / ReadOnlyMemory<T> 往往比直接返回 byte[] 更合适。4. 与内存池配合减少分配
Memory<T>、数组、ArraySegment<T> 怎么选?用数组
用
ArraySegment<T>Memory<T> 更早。Memory<T> 更窄;Span / Memory API 体系配合不如后者自然。用
Memory<T>Span<T> / MemoryPool<T> 统一协作。Memory<T> 往往比 ArraySegment<T> 更现代、更统一。Memory<T> 的几个常见坑1. 跨
await 持有 .Spanawait 的是 Memory<T>Memory<T>.Span2. 忘记管理底层所有权
Memory<T> 来自 MemoryPool<T> 或自定义 owner,那么真正要管理的是 owner 的生命周期。3. 把
Memory<T> 当成拥有者4. 明明只读却还暴露
Memory<T>ReadOnlyMemory<T>。5. 在纯同步高性能路径里滥用
Memory<T>Span<T> / ReadOnlySpan<T> 更简单直接。一套比较务实的实践建议
Memory<T>,下面这些建议很实用:await、做字段或长期持有时使用 Memory<T>;.Span 处理;ReadOnlyMemory<T>;MemoryPool<T> + IMemoryOwner<T>;Memory<T> 和底层所有权混为一谈;Span<T>。总结
Memory<T> 的本质,不是“能跨异步的 Span<T> 这么简单”,而是:它是 .NET 内存体系里那个负责“可持久视图表达”的类型。
Span<T> 负责同步高性能处理;ReadOnlySpan<T> 负责同步只读处理;Memory<T> 负责跨异步、跨组件、跨生命周期地传递视图;ReadOnlyMemory<T> 负责只读的长期视图表达。IOSpan 和异步 API 的边界Memory<T> 基本就是必须掌握的一项基础能力。