.NET 高性能编程里有一对孪生兄弟——Span<T> 和 Memory<T>。两个看起来都在"包一段内存",性能都说自己是"零拷贝、零分配",但只要写到一处 await,IDE 就开始抱怨:"Span<T> 是 ref struct,不能在这里用。"
这就是 Memory<T> 存在的全部动机——
Span<T> 是栈上的极致性能,但它有枷锁;Memory<T> 是它的"跨边界护照",让零拷贝内存能安全地穿越 await、字段、IEnumerable<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> 本体只是 16 字节(64 位下两个指针大小)
|
1.3 ref struct 的枷锁
Span<T> 是 ref struct,这个约束带来两个硬限制:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| ❌ 不能作为 class 的字段
❌ 不能装箱(box) → 不能作为 object
❌ 不能跨 await / yield
❌ 不能实现 IDisposable 之外的接口
❌ 不能作为泛型参数 T(除非加 unmanaged 约束等)
❌ 不能被 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; }
}
|
但这正是现代 .NET 异步编程的主战场——网络、文件、Pipe、Stream 全是异步。这里性能瓶颈也最重——一次性 byte[] 分配堆内存,进 GC,进 LOH,吞吐量直接掉。
需要一个东西:像 Span 一样零拷贝、但又能在异步边界安全传递。这就是 Memory<T>。
二、Memory 的设计:可装箱的"内存护照"
2.1 一句话定义
Memory<T> 是 Span<T> 的"可装箱、可跨 await"版本。它不持有 ref T,而是持有"提取 Span 的能力",所以可以放在堆上。
2.2 内部布局
1
2
3
4
5
6
| 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 只可能是三种:
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
// 自定义(不常见)
Memory<int> m3 = MemoryMarshal.CreateFromPinnedArray(arr, 0, 100);
|
三、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
| Span<byte> span = new byte[100];
Memory<byte> memory = span.ToArray(); // ⚠️ 拷贝了一份!
// 或者用 MemoryMarshal(不安全)
Memory<byte> mem = MemoryMarshal.AsMemory(span); // ⚠️ 不推荐
|
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
| public sealed class NativeMemoryManager<T> : MemoryManager<T>
{
private unsafe T* _ptr;
private readonly int _length;
public unsafe NativeMemoryManager(T* ptr, int length)
{
_ptr = ptr;
_length = length;
}
public override Span<T> GetSpan()
{
unsafe => new Span<T>(_ptr, _length);
}
public override MemoryHandle Pin(int elementIndex = 0)
{
unsafe => new MemoryHandle(_ptr + elementIndex, default, this);
}
public override void Unpin() { /* native 内存不需要 pin */ }
protected override void Dispose(bool disposing)
{
// 释放 native 内存
}
}
// 用法:
unsafe
{
T* ptr = (T*)NativeMemory.Allocate(size);
var manager = new NativeMemoryManager<T>(ptr, size);
Memory<T> mem = manager.Memory;
// 之后像普通 Memory 一样用
}
|
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
| public sealed class ArrayPoolOwner<T> : IMemoryOwner<T>
{
private readonly T[] _array;
private readonly ArrayPool<T> _pool;
public ArrayPoolOwner(T[] array, ArrayPool<T> pool)
{
_array = array;
_pool = pool;
Memory = array;
}
public Memory<T> Memory { get; }
public void Dispose() => _pool.Return(_array);
}
// 用法:
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. 大数组(≥85000 字节)进 LOH,GC 代际高、回收慢
- Web 服务器每秒 1000 请求 × 每请求 4KB buffer
= 4MB/s 分配 → 几秒就触发一次 Gen2 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
18
| // 租
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: false(默认)
ArrayPool<byte>.Shared.Return(buffer, clearArray: false);
}
|
1
2
3
4
5
6
7
8
| ArrayPool.Shared 的实现:
- 每个桶(bucket)对应一种大小的数组
- 线程本地缓存 + 全局共享
- 默认大小:16, 32, 64, ..., 16384, 32768, ..., 1GB
注意:
- 不能保存对归还后的数组的引用(你已不再"拥有"它)
- 不能假设 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
6
7
| MemoryPool<T>.Shared = MemoryPool<T>.Create ArrayPool 内部包装
实际差别:
- ArrayPool<byte>.Rent() → byte[] (要自己管 finally)
- MemoryPool<byte>.Rent() → IMemoryOwner<byte> (using 即可)
推荐:MemoryPool 更现代化、更适合异步代码
|
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(内存泄漏)
// ArrayPool 不会自动回收,必须显式 Return
|
七、实战场景
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+:ReadAsync(Memory<byte>) 扩展方法(基于 ValueTask)
.NET Standard 2.1+:原生支持
推荐用 Memory 重载,零分配 + ValueTask 友好
|
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
| // 大文件逐行解析(避免一次性 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)
{
int read = await stream.ReadAsync(buf.Slice(buffered), ct);
if (read == 0) 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
| // 解析二进制协议(如 MessagePack / Protobuf)
public class ProtocolReader
{
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 > _buffer.Length)
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;
}
}
}
|
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
6
7
8
| // ❌ 高性能反模式
public void Process(Span<byte> data) { ... }
// 如果 data 来自 stackalloc,调用方的栈结束后引用悬空
// ref struct 保证不会逃逸,但每次调用要重新算
// ✅ 改用 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;
Memory<byte> mb = MemoryMarshal.AsBytes(mi);
// mb 是 12 字节,包含 ints 的原始字节
// 用途:序列化、CRC、二进制协议
|
10.2 Cast:类型重新解释
1
2
3
4
5
6
| Memory<byte> bytes = new byte[16];
Memory<int> ints = MemoryMarshal.Cast<byte, int>(bytes);
// ints 现在是 4 个 int,指向同一块内存
// 注意:TFrom 和 TTo 大小必须能整除
// 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:pin 住
1
2
3
4
5
6
7
| byte[] arr = new byte[100];
Memory<byte> mem = MemoryMarshal.CreateFromPinnedArray(arr, 0, 100);
// 创建时数组已被 pin
// mem.Span 拿到的指针不会被 GC 移动
// 用途:与 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
2
3
4
5
6
7
8
9
| 典型结果(.NET 8):
SumArray : ~150 ns
SumSpan : ~120 ns ← 最快(少了边界检查)
SumMemory : ~125 ns ← 几乎和 Span 一样(提取 Span 后循环)
观察:
- Span 和 Memory 性能几乎一致(同步路径下)
- Array 比 Span 略慢(边界检查开销)
- Memory 的提取开销可忽略(仅一次方法调用)
|
十二、小结
本文从 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 是栈上的极致性能,Memory 是它的"跨边界护照"
2. Memory<T>.Span 是一次"安全握手",提取后 Span 仍然不能逃逸
3. 池化(ArrayPool/MemoryPool)是高性能 .NET 的标配——少了它就是每秒万次的 GC
|
下次写异步 IO 或文本解析时,先用 MemoryPool<T>.Shared.Rent() + using,性能立刻上一个台阶。