1. 介绍
一切开始于 .NET 的开发者想提升大数据量处理的速度,并且尽可能给开发者既方便又安全的工具。最先出现的是 Span<T>,它是对连续内存区域的“窗口”,可以指向数组、字符串,甚至是 .NET 之外分配的内存(比如通过 P/Invoke)。
不过 Span<T> 有一个强限制:它必须只活在栈上。不能把它存在类字段里,不能从方法返回,不能在异步方法之间传递。原因是安全:如果有人保留了对已经不存在内存的引用,应用就会崩溃。
有时候你需要从方法返回内存切片、把它放到集合或类字段里,或者在异步 API 中使用。这里就轮到 Memory<T> 上场——它本质上是 Span<T> 的“长期存在”版本,可以放到堆上、在线程间传递、作为属性和对象的一部分,行为像一个正常的 .NET 对象。
还有一个“只读大哥”—— ReadOnlyMemory<T>,顾名思义,它不允许通过这个视图修改底层数据,但可以在任何需要读取的地方使用。
2. Span<T> 和 Memory<T> 的区别
下面是个简单表格,方便对比:
|
|
|
|---|---|---|
| 存放位置 | 只在栈上 (stack only) | 可以在栈上也可以在堆上 (heap/stack) |
| 可以存储在字段中 | ❌ 不可以 | ✅ 可以 |
| 可以从方法返回 | ❌ 不可以 | ✅ 可以 |
| 异步 / await 方法 | ❌ 不行 | ✅ 可以 |
| 可变性 | ✅ 有对应的 ReadOnlySpan<T> | ✅ 有对应的 ReadOnlyMemory<T> |
| 支持切片 | ✅ 支持 | ✅ 支持 |
如果你只需要在方法内部快速遍历数据——用 Span<T>。如果你需要把结果返回到外部或放到类字段里——用 Memory<T>。如果数据只是读的——用 ReadOnlyMemory<T>。
3. Memory<T> 的签名和基本结构
和常规一样:Memory<T> 是泛型类型。你可以创建 Memory<int>、Memory<byte>、Memory<char>,甚至 Memory<MyType>。在 Memory<T> 内部保存着对数组、字符串或其他数据源的引用,以及一个范围(从哪个索引开始和有多少元素)。
如果想从 Memory<T> 获取快速处理能力,可以用它的属性 Span —— 这样你会立刻得到一个 Span<T>,可以在同步方法里使用。
4. 怎么创建 Memory<T>:实践
示例 1. 从数组创建
int[] numbers = { 1, 2, 3, 4, 5, 6 };
Memory<int> memory = new Memory<int>(numbers); // 整个数组
// 可以取一个 "切片" —— 数组的一部分
Memory<int> slice = memory.Slice(2, 3); // 元素 2、3 和 4
示例 2. 从字符串创建(通过 Memory<char>)
string text = "你好,世界!";
Memory<char> charMemory = text.AsMemory(); // 整个文本作为内存
Memory<char> subMemory = charMemory.Slice(7, 3); // 从第7个字符开始,3个字符("世界")
示例 3. 使用 ReadOnlyMemory<T>
用法完全一样,只是不能通过它修改数据:
int[] data = { 10, 20, 30, 40 };
ReadOnlyMemory<int> readOnly = data; // 通过这个对象不能修改底层数据
5. 在 Memory<T> 和 Span<T> 之间转换
直接用 Memory<T> 达到 Span<T> 那样的灵活和速度是不行的——它们的设计目标不同。不过当你真的需要快速处理一段内存时,可以通过属性 Span 从 memory 中获得一个即时的 Span<T>:
void ProcessData(Memory<int> memory)
{
Span<int> span = memory.Span;
for (int i = 0; i < span.Length; i++)
{
span[i] += 100;
}
}
注意:Span<T> 只在方法内部有效。如果尝试把它返回出去,编译器会报错。
对 ReadOnlyMemory<T> 也是类似的,你会得到一个 ReadOnlySpan<T>,它不允许你修改数据:
void PrintData(ReadOnlyMemory<int> memory)
{
ReadOnlySpan<int> roSpan = memory.Span;
foreach (var item in roSpan)
Console.WriteLine(item);
}
6. 在真实场景中的使用
异步处理数据
这正是 Memory<T> 发光的地方。它可以用于异步方法!比如异步读文件:
using System.IO;
using System.Threading.Tasks;
public async Task ReadFileAsync(string path)
{
byte[] buffer = new byte[4096];
using var stream = File.OpenRead(path);
int bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length));
// 现在你可以处理 buffer 了
}
这里 AsMemory 把缓冲区直接传给了异步方法,不会出现像用 Span<T> 那样的作用域或内存销毁问题。
在属性和字段中存储切片
有时候你想做个类,存一个大数组的“片段”以便稍后传递:
class DataChunk
{
public Memory<byte> Data { get; }
public DataChunk(Memory<byte> data)
{
Data = data;
}
}
7. 和集合、字符串、数组一起用
和数组
最常见的用法:
byte[] bytes = { 1, 2, 3, 4, 5 };
Memory<byte> mem = bytes; // 整个数组
Memory<byte> part = mem.Slice(2); // 从第3个元素到末尾
和字符串
通过 AsMemory():
string hello = "Hello, Memory!";
ReadOnlyMemory<char> mem = hello.AsMemory(6, 6); // "Memory"
和集合(例如 List<T>)
不能直接从 List<T> 创建 Memory<T>。只能先转成数组:
List<int> list = new List<int> { 1, 2, 3 };
Memory<int> mem = list.ToArray(); // 这是一次复制,不是引用!
注意:如果想避免复制——把数据保存在数组中。
8. 使用 Memory<T> 的常见错误
错误 №1:试图在类字段中用 Span<T> 替代 Memory<T>。 不能把 Span<T> 存在类字段里,因为它绑定到栈。编译器会报错。要在堆上保存,就用 Memory<T>。
错误 №2:认为切片会复制数据。 Memory<T> 不会复制数据,它只是创建了一个指向现有数组的“窗口”。如果你通过一个 Memory<T> 修改数据,所有引用同一块内存的视图都会看到变化。
错误 №3:试图直接从 List<T> 创建 Memory<T>。 Memory<T> 只能和数组一起工作,因为 List<T> 可能会改变底层数据在内存中的位置。用 ToArray() 把列表转成数组。
错误 №4:忽视 ReadOnlyMemory<T> 在不可变数据上的价值。 如果数据不需要修改,用 ReadOnlyMemory<T> 替代 Memory<T> 会更安全。
GO TO FULL VERSION