写在前面
.NET 高性能编程里常同时出现 Span<T> 和 Memory<T>。两者都能表示一段连续内存并避免不必要的数据复制;包装本身通常不分配,但装箱、ToArray() 和底层所有者仍可能分配。异步边界也是二者的重要分工点。
这就是 Memory<T> 存在的全部动机——
Span<T> 是受 ref-safety 约束的同步视图;Memory<T> 是可存储、可由异步操作持有的内存描述。二者都能在不复制底层数据的情况下切片,但都不拥有数据生命周期。
理解这层关系,比记住一堆 API 重要。本文围绕这条主线,把 Memory<T> 从底层布局到使用陷阱彻底讲透。
一、回到 Span:极致性能的代价
要看懂 Memory,先看 Span 解决了什么、付出了什么。
1.1 没有 Span 之前
1
2
3
4
5
6
7
8
9
10
11
12
13
| // 想对"一段连续数据"做处理,但来源五花八门:
void ProcessArray(byte[] arr, int offset, int len) { ... }
void ProcessString(string s, int offset, int len) { ... }
void ProcessPointer(IntPtr ptr, int len) { ... }
// 调用方:
ProcessArray(buffer, 10, 5);
ProcessString(text, 1, 3);
// 问题:
// - 每种来源要写一份重复代码
// - 调用方需要传 (array, offset, length) 三元组,容易出错
// - string 内部是 UTF-16,byte[] 是字节, IntPtr 是非托管,三者之间不能互通
|
1.2 Span 的设计:内存的"通用切片"
Span<T> 的内部其实就是两个字段:
1
2
3
4
5
| public readonly ref struct Span<T>
{
private readonly ref T _pointer; // 对内存的引用(byref)
private readonly int _length;
}
|
1
2
3
4
5
6
7
| 关键点:
1. ref T _pointer:不是普通指针,是"指向 T 的引用"(managed pointer)
→ 既能指向托管数组、也能指向 stackalloc、还能指向 native heap
→ GC 知道它在引用什么,不会被错判回收
2. int _length:长度
Span<T> 的具体大小和布局属于运行时实现细节,不应写入协议或持久化格式
|
1.3 ref struct 的枷锁
Span<T> 是 ref struct。以下是常见限制;较新的 C# 已放宽部分规则,具体以项目语言版本为准:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| ❌ 不能作为 class 的字段
❌ 不能装箱(box) → 不能作为 object
❌ 不能跨 await / yield
❌ 在旧语言版本中不能实现接口
❌ 不能用于未声明允许 ref struct 的泛型参数
❌ 不能被 lambda 捕获
为什么这么严?
- ref T _pointer 是"内部指针"
- 一旦允许装箱,就可能逃逸到堆
- 一旦逃逸到堆,引用的目标(可能是 stackalloc 的栈内存)可能已经失效
→ 引用悬空 → GC 灾难 → 类型安全崩塌
所以 ref struct 必须"留在栈上",编译期阻止任何逃逸。
|
1.4 痛点:写异步代码时处处碰壁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| public async Task ProcessAsync(byte[] buffer)
{
Span<byte> span = buffer.AsSpan();
// ❌ 编译错误:Span 不能作为 lambda 字段
await Task.Run(() => {
// ... span ...
});
// ❌ 编译错误:Span 不能跨 await
await DoSomethingAsync();
Console.WriteLine(span.Length);
// ❌ 编译错误:Span 不能作为字段
// class Holder { public Span<int> Data; }
}
|
异步 I/O 经常需要把缓冲区保留到操作完成。普通 byte[] 并非天然有问题;只有高频或大对象分配造成可观测 GC 压力时,池化才可能带来收益。
需要一个能描述同一底层缓冲区、又可由异步操作安全持有的类型——这就是 Memory<T>。
二、Memory 的设计:可装箱的"内存护照"
2.1 一句话定义
Memory<T> 是可以存入字段、由异步操作持有,并能按需取得 Span<T> 视图的普通结构体。它可以装箱,但装箱会产生分配,通常不应作为卖点。
2.2 概念模型与当前实现
1
2
3
4
5
6
7
| // 用于理解的简化模型;私有字段属于运行时实现细节,不是公共契约
public readonly struct Memory<T>
{
private readonly object _object; // 内存来源(可能是 T[]、string、MemoryManager<T>)
private readonly int _index; // 起始偏移
private readonly int _length; // 长度
}
|
1
2
3
4
5
6
7
8
9
10
11
| 和 Span 的核心差别:
Span<T>:ref T _pointer ← GC 直接跟踪
Memory<T>:object _object ← 通过对象间接引用
为什么这样设计?
- 不持有 ref,所以 Memory 是普通 struct(不是 ref struct)
- 可以装箱、可以跨 await、可以作为字段
- 但 Span 暂时拿不到 → 需要"提取"
Span 怎么提取?
Span<T> span = memory.Span; ← 这个 getter 内部做"安全提取"
|
2.3 为什么 Memory 不直接持有 ref
1
2
3
4
5
6
7
| 假设 Memory<T> 持有 ref T _pointer:
- Memory 是 struct,可以装箱
- 装箱后 ref T 留在堆上
- 如果原数据是 stackalloc → 装箱后栈释放 → ref 悬空 → 崩溃
→ Memory 设计上就不允许持有 ref,只能持有 object
→ 真要拿 Span 时,从 object 中提取,提取时编译器/运行时知道这是同步操作,安全
|
2.4 三种合法的 _object 类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| Memory<T>._object 只可能是以下几种(default(Memory<T>) 时为 null):
1. T[] 数组
- new int[100].AsMemory()
- GC 知道这是托管堆上的数组,安全
2. string(仅当 T 为 char)
- "hello".AsMemory()
- string 不可变 → ReadOnlyMemory<char>
3. MemoryManager<T>(自定义)
- 例如 MemoryManager<char> 包装 native 内存
- 自定义 Pin() 让 GC 知道何时 pin
4. null(表示空 Memory 或 default)
|
1
2
3
4
5
6
7
8
9
| // 三种典型构造方式
byte[] arr = new byte[100];
Memory<byte> m1 = arr.AsMemory(); // _object = arr
string s = "hello";
ReadOnlyMemory<char> m2 = s.AsMemory(); // _object = s
// 自定义(不常见)
// MemoryManager<T> 的示例见 4.3;CreateFromPinnedArray 要求数组已由调用方固定
|
三、Memory 与 Span 的转换:一次"安全握手"
3.1 Memory → Span
1
2
| Memory<byte> memory = new byte[1024];
Span<byte> span = memory.Span; // ← 提取
|
1
2
3
4
5
6
7
8
9
10
| 这一步是"零拷贝"的:
- 内部根据 _object 类型生成 Span
- 数组:span = new Span<T>((T[])_object, _index, _length)
- string:span = ref Unsafe.AsRef(in s.GetFirstChar()) + offset
- MemoryManager:调用 MemoryManager.GetSpan()
为什么这次"提取"是安全的?
- 提取发生在栈上
- 拿到的 Span<T> 不能逃逸(ref struct 约束)
- 一旦 Span 用完,对象引用还在 Memory 里持有
|
3.2 Span → Memory:不能直接转
1
2
| Span<byte> span = stackalloc byte[100];
Memory<byte> memory = span; // ❌ 编译错误!
|
为什么不行?因为 Span 可能指向栈内存(stackalloc),如果转成 Memory 装箱到堆上,栈帧结束后引用悬空。
但如果是数组-backed 的 Span,可以从原数组重新构造(零拷贝):
1
2
3
4
5
| byte[] arr = new byte[100];
Span<byte> span = arr;
Memory<byte> memory = new Memory<byte>(arr); // 从原数组构造,不拷贝
// span.ToArray() 也能得到 Memory 可用的数据,但 ⚠️ 会拷贝一份
|
注:MemoryMarshal.AsMemory 存在,但签名是 ReadOnlyMemory<T> → Memory<T>(去掉只读性),
并没有接收 Span<T> 的重载;标准库里不存在 Span → Memory 的直接转换 API。
3.3 Memory 的 Slice:返回 Memory
1
2
3
4
5
6
| Memory<byte> mem = new byte[100];
Memory<byte> slice = mem.Slice(10, 20);
// 内部就是:
// return new Memory<T>(_object, _index + 10, 20);
// 仍然不持 ref,仍然可以装箱/跨 await
|
而 Span 的 Slice:
1
2
3
4
5
| Span<byte> span = mem.Span;
Span<byte> slice = span.Slice(10, 20);
// 内部是:return new Span<T>(ref _pointer[10], 20);
// 仍然是 ref struct
|
3.4 转换成本对比
| 操作 | 成本 |
|---|
Memory<T>.Span 提取 | 几纳秒(一次方法调用 + 类型判断) |
Memory<T>.Slice | 16 字节 struct 创建 |
Span<T>.Slice | 16 字节 struct 创建 |
Memory<T>.ToArray() | 拷贝整个数组(O(n)) |
Memory<T>.Pin() | 创建 MemoryHandle,pin 对象 |
四、Memory 的三种来源
4.1 来自托管数组
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| byte[] buffer = new byte[4096];
Memory<byte> mem = buffer.AsMemory();
// 切片
Memory<byte> slice = mem.Slice(0, 1024);
// 跨 await
await ProcessAsync(slice);
async Task ProcessAsync(Memory<byte> data)
{
// 在 await 前后都能用
Span<byte> span = data.Span;
span[0] = 0xFF;
await Task.Yield();
Console.WriteLine(span[0]); // 0xFF
}
|
4.2 来自字符串(仅 char)
1
2
3
4
5
6
7
8
| string text = "Hello, World!";
ReadOnlyMemory<char> mem = text.AsMemory();
ReadOnlyMemory<char> word = mem.Slice(7, 5);
Console.WriteLine(word.ToString()); // "World"
// 跨 await 安全
await ProcessAsync(word);
|
1
2
3
4
| 注意:
- string 是不可变的 → ReadOnlyMemory<char>
- 不能改成 Memory<char>
- 想要可变字符串用 StringBuilder 或 char[]
|
4.3 来自 MemoryManager(自定义)
MemoryManager<T> 是抽象类,让你包装"非标准内存"为 Memory<T>:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| using System.Buffers;
using System.Runtime.InteropServices;
public sealed unsafe class NativeMemoryManager : MemoryManager<byte>
{
private byte* _ptr;
private readonly int _length;
public NativeMemoryManager(int length)
{
_ptr = (byte*)NativeMemory.Alloc((nuint)length);
_length = length;
}
public override Span<byte> GetSpan() => new(_ptr, _length);
public override MemoryHandle Pin(int elementIndex = 0)
{
if ((uint)elementIndex > (uint)_length)
throw new ArgumentOutOfRangeException(nameof(elementIndex));
return new MemoryHandle(_ptr + elementIndex, default, this);
}
public override void Unpin() { /* native 内存不需要 pin */ }
protected override void Dispose(bool disposing)
{
if (_ptr is not null)
{
NativeMemory.Free(_ptr);
_ptr = null;
}
}
}
// 用法:
using var manager = new NativeMemoryManager(4096);
Memory<byte> mem = manager.Memory;
// mem 只能在 manager.Dispose() 之前使用
|
1
2
3
4
5
6
7
| MemoryManager<T> 的常见应用:
- 包装 native 内存(malloc、mmap)
- 包装 unmanaged 集合
- 包装内存映射文件
- 包装 GC pin 的对象
ASP.NET Core、Pipelines 内部都用 MemoryManager 包装底层缓冲区。
|
五、IMemoryOwner:所有权管理
5.1 为什么需要"所有者"
1
2
3
4
5
6
7
8
9
10
| 问题:Memory<T> 是 struct,不持有"释放"概念
谁该释放底层 buffer?
场景:
- 你从 ArrayPool 租了一个 buffer
- 多个方法共享 Memory<byte>
- 用完应该归还给 Pool
Memory<byte> 本身没有 Return() 方法
→ 需要"所有者"对象负责归还
|
5.2 IMemoryOwner 接口
1
2
3
4
| public interface IMemoryOwner<T> : IDisposable
{
Memory<T> Memory { get; }
}
|
1
2
3
4
5
6
7
8
9
10
11
12
| 谁实现:
- MemoryPool<T>.Shared.Rent() 返回 IMemoryOwner<T>
- 自定义实现(包装 ArrayPool)
使用模式:
using var owner = MemoryPool<byte>.Shared.Rent(1024);
Memory<byte> mem = owner.Memory;
// 传给其他方法(不需要释放责任)
await ProcessAsync(mem);
// using 自动释放(归还给 Pool)
|
5.3 IMemoryOwner 的实现示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| using System.Buffers;
using System.Runtime.CompilerServices;
public sealed class ArrayPoolOwner<T> : IMemoryOwner<T>
{
private T[]? _array;
private readonly ArrayPool<T> _pool;
public ArrayPoolOwner(T[] array, ArrayPool<T> pool)
{
_array = array;
_pool = pool;
}
public Memory<T> Memory => _array ?? throw new ObjectDisposedException(nameof(ArrayPoolOwner<T>));
public void Dispose()
{
var array = Interlocked.Exchange(ref _array, null);
if (array is not null)
_pool.Return(array, RuntimeHelpers.IsReferenceOrContainsReferences<T>());
}
}
// 用法:
public IMemoryOwner<byte> GetBuffer(int size)
{
var array = ArrayPool<byte>.Shared.Rent(size);
return new ArrayPoolOwner<byte>(array, ArrayPool<byte>.Shared);
}
|
1
2
3
4
5
| 设计哲学:
- Memory<T> = "使用权"(可以读、可以切片、可以传递)
- IMemoryOwner<T> = "所有权"(谁负责释放)
类似 C++ 的 unique_ptr / shared_ptr 模式
|
六、MemoryPool 与 ArrayPool:池化是关键
6.1 为什么池化
1
2
3
4
5
6
7
8
9
10
11
12
13
| 每次 new byte[4096] 的代价:
1. 分配(堆上找空间)
2. GC 跟踪
3. 触发 GC 时回收
4. 总大小约 85,000 字节及以上的对象通常进入 LOH;阈值和回收行为应以目标运行时为准
- Web 服务器每秒 1000 请求 × 每请求 4KB buffer = 约 4MB/s 短命对象分配
- 这会增加 GC 压力;是否以及何时触发 Gen2 GC 取决于 GC 模式、堆预算、存活对象和内存压力
池化的核心:
- 租一个 buffer(Rent):池里有就用,没有就 new
- 用完归还(Return):放回池,下次复用
- 降低分配率,但池和新建数组仍由 GC 管理
|
6.2 ArrayPool:池化的底层
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // 租
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
// 注意:实际返回的数组可能比请求的大(向上取整)
// 长度看 buffer.Length,不是 4096
try
{
// 用 buffer
int read = await stream.ReadAsync(buffer, 0, 4096);
}
finally
{
// 归还
ArrayPool<byte>.Shared.Return(buffer);
// 敏感数据需要清理时使用 clearArray: true;同一数组只能归还一次
}
|
1
2
3
4
5
6
7
8
| ArrayPool.Shared 的实现:
- 每个桶(bucket)对应一种大小的数组
- 线程本地缓存 + 全局共享
- 具体桶大小和缓存策略属于运行时实现细节,不应作为应用契约
注意:
- 不能保存对归还后的数组的引用(你已不再"拥有"它)
- 不能假设 Rent 的长度正好等于请求长度
|
6.3 MemoryPool:池化的 Memory 包装
1
2
3
4
5
6
7
8
9
10
11
| // MemoryPool<T> 是 ArrayPool<T> 的"Memory 化"包装
using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(4096);
Memory<byte> mem = owner.Memory;
// mem.Length 可能 > 4096(实际数组大小)
// 切片到精确大小
Memory<byte> exact = mem.Slice(0, 4096);
await ProcessAsync(exact);
// using 自动归还
|
1
2
3
4
5
| 实际差别:
- ArrayPool<byte>.Rent() → byte[] (要自己管 finally)
- MemoryPool<byte>.Rent() → IMemoryOwner<byte> (using 即可)
选择依据是 API 是否需要显式所有权,不是简单的新旧关系
|
6.4 池化的坑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // 坑 1:使用了归还后的数组
byte[] buf = ArrayPool<byte>.Shared.Rent(100);
ArrayPool<byte>.Shared.Return(buf);
buf[0] = 1; // ⚠️ buf 已不属于你,可能被别人覆盖
// 坑 2:在 using 之后还使用 Memory
async Task BadAsync()
{
Memory<byte> mem;
using (var owner = MemoryPool<byte>.Shared.Rent(100))
{
mem = owner.Memory; // 拿到引用
} // ← owner 已 Dispose,buffer 已归还!
mem.Span[0] = 1; // ⚠️ 未定义行为
}
// 坑 3:多线程同时 Rent + Return 同一数组
// 需要协议保证只在使用期间持有
// 坑 4:忘记 Return 会让数组长期离开池并增加后续分配压力;
// 数组最终仍可由 GC 回收,不属于不可回收的原生内存泄漏
|
七、实战场景
7.1 高性能 IO:Stream.ReadAsync(Span) vs ReadAsync(Memory)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| // ❌ 同步 Span 版本:不能跨 await
public byte[] ReadAll(Stream stream)
{
using var ms = new MemoryStream();
Span<byte> buf = stackalloc byte[4096];
int read;
while ((read = stream.Read(buf)) > 0)
{
ms.Write(buf.Slice(0, read));
}
return ms.ToArray();
}
// ✅ 异步 Memory 版本
public async Task<byte[]> ReadAllAsync(Stream stream)
{
using var ms = new MemoryStream();
using var owner = MemoryPool<byte>.Shared.Rent(4096);
Memory<byte> buf = owner.Memory;
int read;
while ((read = await stream.ReadAsync(buf)) > 0)
{
ms.Write(buf.Span.Slice(0, read));
}
return ms.ToArray();
}
|
1
2
3
4
5
6
| Stream.ReadAsync 的演进:
.NET Framework:ReadAsync(byte[], offset, count)
.NET Core 2.1+:Stream 的 ReadAsync(Memory<byte>) 虚方法(返回 ValueTask<int>)
.NET Standard 2.1+:原生支持
异步 API 优先使用 Memory 重载;是否零分配取决于具体 Stream 实现和操作是否同步完成
|
7.2 文本解析器:避免 LOH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| // 大文件逐行解析(避免一次性 ReadAllText)
public async IAsyncEnumerable<(int Line, string Text)> ParseLinesAsync(
Stream stream,
[EnumeratorCancellation] CancellationToken ct = default)
{
using var owner = MemoryPool<byte>.Shared.Rent(64 * 1024);
Memory<byte> buf = owner.Memory;
int buffered = 0;
int lineNo = 0;
while (true)
{
if (buffered == buf.Length)
throw new InvalidDataException("Line exceeds the 64 KiB buffer.");
int read = await stream.ReadAsync(buf.Slice(buffered), ct);
if (read == 0)
{
if (buffered > 0)
yield return (++lineNo, Encoding.UTF8.GetString(buf.Span[..buffered]));
break;
}
buffered += read;
// 找换行
var data = buf.Slice(0, buffered);
int idx;
while ((idx = data.Span.IndexOf((byte)'\n')) >= 0)
{
lineNo++;
var line = data.Slice(0, idx);
yield return (lineNo, Encoding.UTF8.GetString(line.Span));
data = data.Slice(idx + 1);
}
// 剩余的移到 buf 开头
data.Span.CopyTo(buf.Span);
buffered = data.Length;
}
}
|
7.3 协议解析:Memory 切片传递
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| // 解析二进制协议(如 MessagePack / Protobuf)
public sealed class ProtocolReader : IDisposable
{
private readonly Stream _stream;
private readonly IMemoryOwner<byte> _owner;
private Memory<byte> _buffer;
public ProtocolReader(Stream stream, int bufferSize = 4096)
{
_stream = stream;
_owner = MemoryPool<byte>.Shared.Rent(bufferSize);
_buffer = _owner.Memory;
}
public async Task<ReadOnlyMemory<byte>> ReadMessageAsync()
{
// 读 header(4 字节长度)
await ReadExactAsync(_buffer.Slice(0, 4));
int length = BitConverter.ToInt32(_buffer.Span.Slice(0, 4));
// 读 body
if (length < 0 || length > _buffer.Length - 4)
throw new InvalidOperationException("Message too large");
var body = _buffer.Slice(4, length);
await ReadExactAsync(body);
return body;
}
private async Task ReadExactAsync(Memory<byte> dst)
{
int total = 0;
while (total < dst.Length)
{
int read = await _stream.ReadAsync(dst.Slice(total));
if (read == 0) throw new EndOfStreamException();
total += read;
}
}
public void Dispose() => _owner.Dispose();
}
|
7.4 ASP.NET Core 内部的 Memory
1
2
3
4
5
6
7
8
9
10
| ASP.NET Core 大量用 Memory<T>:
- HttpContext.Request.Body : Stream,内部用 PipeReader
- PipeReader 读出 ReadOnlySequence<byte>(一堆 Memory 拼接)
- Response.Body.WriteAsync(ReadOnlyMemory<byte>)
- SignalR 传输用 Memory<byte>
System.IO.Pipelines(Pipe / PipeReader / PipeWriter):
- 微软的"高性能 IO 抽象"
- 替代 Stream 的"读一点写一点"模式
- 内部全部 Memory<T> + IMemoryOwner<T>
|
八、ReadOnlyMemory 与可变性
1
2
3
4
5
6
7
8
9
| Memory<byte> mem = new byte[100];
ReadOnlyMemory<byte> rom = mem; // 隐式转换
// ReadOnlyMemory 不能改
rom.Span[0] = 1; // ❌ 编译错误
// 但底层是同一个数组!
mem.Span[0] = 1;
Console.WriteLine(rom.Span[0]); // 1
|
1
2
3
4
5
6
7
8
| 观察:
- ReadOnlyMemory<T> ≠ 不可变数据
- 只是你"看不到"修改权限
- 实际数据可能被另一个 Memory 引用修改
真正不可变的:
- ImmutableArray<T>
- 用 ToArray() 拷贝出来的副本
|
九、常见陷阱与最佳实践
9.1 持有 Memory 实际持有大数组
1
2
3
4
5
6
7
8
9
| // 危险:Memory 是"切片",但底层对象是整个数组
byte[] big = new byte[10_000_000];
Memory<byte> small = big.AsMemory(0, 10);
// small 看起来只有 10 字节,但 big 不能被 GC 回收!
return small; // small 存活期间会保留整个大数组;这是内存保留,不是不可回收泄漏
// 解决:用 ToArray()(如果数据真的小)
return small.ToArray();
|
1
2
3
4
| Memory<T>.Pin() 也会 pin 整个数组:
var handle = mem.Pin();
// 在 handle Dispose 之前,整个底层数组被 GC pin
// 不能移动、不能回收
|
9.2 跨 Method边界传递 Span
1
2
3
4
5
| // ✅ 同步处理应优先接受 Span:编译器会阻止它逃逸
public void Process(Span<byte> data) { ... }
// 异步方法或需要把缓冲区存入字段时再使用 Memory
public Task ProcessAsync(Memory<byte> data) { ... }
|
9.3 用 ReadOnlyMemory 表达契约
1
2
3
4
5
6
| // 表达"我只读不改"的契约
public Task<int> ParseAsync(ReadOnlyMemory<byte> data);
// 调用方既能传 Memory 也能传 ReadOnlyMemory
Memory<byte> m = ...;
await ParseAsync(m); // 隐式转换
|
9.4 避免 Memory 装箱
1
2
3
4
5
6
| // Memory 是 struct,但装箱到 object 仍然是性能损失
object boxed = (object)mem; // ⚠️ 装箱
async Task<object> GetBoxedAsync() => mem; // ⚠️ 装箱
// 直接用 Memory<T>,不要用 object
async Task<Memory<byte>> GetAsync() => mem;
|
9.5 验证生命周期
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| public async Task BadAsync()
{
Memory<byte> mem;
using (var owner = MemoryPool<byte>.Shared.Rent(100))
{
mem = owner.Memory;
// ❌ 把 mem 注册到回调,回调在 using 之后执行
RegisterCallback(() => {
mem.Span[0] = 1; // 已归还的 buffer!
});
}
}
// ✅ 正确:所有使用都在 using 范围内
public async Task GoodAsync()
{
using var owner = MemoryPool<byte>.Shared.Rent(100);
Memory<byte> mem = owner.Memory;
await ProcessAsync(mem); // 整个 await 都在 using 范围内
}
|
十、MemoryMarshal:不安全边界
MemoryMarshal 提供"破坏封装"的低级操作,只在性能关键路径上用。
10.1 AsBytes:把 Memory 当 byte 看
1
2
3
4
5
6
7
| int[] ints = new int[] { 1, 2, 3 };
Memory<int> mi = ints;
Span<byte> mb = MemoryMarshal.AsBytes(mi.Span);
// mb 是 12 字节,包含 ints 的原始字节
// 用途:序列化、CRC、二进制协议
|
10.2 Cast:类型重新解释
1
2
3
4
5
6
| Memory<byte> bytes = new byte[16];
Span<int> ints = MemoryMarshal.Cast<byte, int>(bytes.Span);
// ints 现在是 4 个 int,指向同一块内存
// 结果长度按总字节数 / sizeof(TTo) 计算;末尾不足一个 TTo 的字节不会出现在结果 Span 中
// 这里 byte (1) → int (4):16/4 = 4 个 int
|
10.3 TryGetArray:拿到原数组引用
1
2
3
4
5
6
7
8
9
10
| Memory<byte> mem = new byte[100];
if (MemoryMarshal.TryGetArray(mem, out ArraySegment<byte> seg))
{
// seg.Array 是原数组
// seg.Offset 是偏移
// seg.Count 是长度
// 用途:调用旧 API(如 Stream.Read(byte[], offset, count))
}
|
1
2
3
4
5
6
| TryGetArray 失败的场景:
- Memory 来自 string(仅 char)
- Memory 来自 MemoryManager<T>
- 没有底层托管数组
→ 失败时需要 fallback:拷贝到临时数组
|
10.4 CreateFromPinnedArray:包装已固定的数组
1
2
3
4
5
| byte[] arr = GC.AllocateUninitializedArray<byte>(100, pinned: true);
// 该 API 不会替你执行 pin;数组必须已由调用方固定
Memory<byte> mem = MemoryMarshal.CreateFromPinnedArray(arr, 0, 100);
// 用途:与 native 代码互操作(P/Invoke)
|
十一、性能基准
用 BenchmarkDotNet 看 Span vs Memory vs Array:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| // 基准测试代码
[MemoryDiagnoser]
public class MemBench
{
private byte[] _data = new byte[1024];
[Benchmark]
public int SumArray()
{
int sum = 0;
for (int i = 0; i < _data.Length; i++) sum += _data[i];
return sum;
}
[Benchmark]
public int SumSpan()
{
int sum = 0;
foreach (var b in _data.AsSpan()) sum += b;
return sum;
}
[Benchmark]
public int SumMemory()
{
int sum = 0;
foreach (var b in _data.AsMemory().Span) sum += b;
return sum;
}
}
|
1
| 不要在没有 CPU、运行时版本、BenchmarkDotNet 输出和统计误差的情况下给出“典型结果”。JIT 可能对三种循环做出相同或不同优化;应在目标运行时运行该基准,并检查均值、误差、反汇编与分配列。
|
十二、小结
本文从 Span<T> 的"栈枷锁"出发,系统讲了 Memory<T>:
Memory<T> 的内部布局:object + index + length(不持 ref)- 为什么这样设计:可装箱、可跨 await、可作为字段
- 三种来源:T[] 数组、string、MemoryManager
- 与 Span 的转换:
Memory.Span 提取、不能反向 IMemoryOwner<T> 的所有权语义ArrayPool<T> 与 MemoryPool<T> 的池化机制- 实战场景:异步 IO、文本解析、协议解析、ASP.NET Core 内部
- 常见陷阱:持有切片但底层数组泄露、生命周期错位、装箱
MemoryMarshal 的不安全操作
1
2
3
4
| 记住三句话:
1. Span 是受 ref-safety 约束的同步视图,Memory 是可存储、可异步持有的描述
2. Memory<T>.Span 是一次"安全握手",提取后 Span 仍然不能逃逸
3. 池化用于解决已经测量到的分配压力,同时必须严格遵守所有权和租约
|
写异步 I/O 或解析器时,先选清晰、正确的所有权模型;只有 profiling 显示缓冲区分配是瓶颈时,再引入 ArrayPool<T> 或 MemoryPool<T>。
参考资料