C# 中的 Span<T> 与 Memory<T> 详解:原理、区别与最佳实践指南

在现代 .NET 高性能开发中,Span<T> 与 Memory<T> 已成为绕不开的核心工具。它们的目标非常明确:减少内存分配、避免数据复制、降低 GC 压力。尤其在处理字符串、二进制数据、网络流或高频计算场景中,这两个类型几乎是性能优化的标配。本文将从原理、区别到最佳实践,系统讲清它们的用法。

什么是 Span<T>?

Span<T> 是一种表示连续内存区域的轻量级类型,可以指向:

  • 数组
  • 栈内存(stackalloc)
  • 非托管内存

它本质上是一个内存视图,不会复制数据。

Span<T> 的核心特点

  • 零分配(Zero Allocation)
  • 支持切片(Slice)但不复制数据
  • 只能存在于栈上(ref struct)

示例:

byte[] data = GetData();
Span<byte> slice = data.AsSpan(100, 50);

这里不会创建新数组,而只是引用原内存的一部分。

什么是 Memory<T>?

Memory<T> 是 Span<T> 的可扩展版本,可以在堆上存活,支持更复杂场景。

Memory<T> 的核心特点

  • 可作为字段、返回值、参数
  • 可跨 async/await
  • 可长期持有

示例:

Memory<byte> memory = new byte[1024];
Span<byte> span = memory.Span;

Memory<T> 可以理解为可以长期存活的 Span 容器。

Span<T> vs Memory<T> 核心区别

特性 Span<T> Memory<T>
存储位置 仅栈 栈 + 堆
是否可用于 async 不可以 可以
是否可作为字段 不可以 可以
性能 更高 略低
使用方式 直接操作 通过 .Span

关键本质:

  • Span<T> = 极致性能(但受限制)
  • Memory<T> = 更灵活(但略有开销)

官方也明确指出:Span<T> 不能用于异步或跨线程场景,而 Memory<T> 正是为此设计的补充。

为什么它们性能更强?

传统代码中:

var sub = array.Skip(100).Take(50).ToArray(); // 分配+复制

问题:

  • 创建新数组
  • 增加 GC 压力
  • 数据复制耗时

而 Span<T>:

var sub = array.AsSpan(100, 50); // 无分配

优势:

  • 避免堆分配
  • 避免内存复制
  • 减少 GC 频率

这些优化在高频场景(如 JSON 解析、网络通信)中尤为明显。

典型使用场景

1. 字符串与文本处理

ReadOnlySpan<char> span = "hello world".AsSpan();
var slice = span.Slice(0, 5);

适用于:

  • JSON解析
  • CSV处理
  • 日志处理

2. 二进制协议解析

Span<byte> buffer = stackalloc byte[256];
适用于:
  • 网络协议
  • 文件解析

3. 高性能 I/O(推荐 Memory<T>)

async Task ReadAsync(Stream stream, Memory<byte> buffer)
{
    await stream.ReadAsync(buffer);
}

4. 管道与缓冲区(ASP.NET Core)

结合:

  • IBufferWriter<T>
  • PipeReader

可以实现零拷贝数据流。

最佳实践(重点)

1. 优先使用 Span<T>

如果满足同步方法、生命周期短、不跨线程,优先用 Span<T>(性能最佳)

2. 跨 async 用 Memory<T>

 
Task ProcessAsync(Memory<byte> buffer)

避免使用 Span<T>(会编译报错)

3. 使用 ReadOnlySpan<T>

 
ReadOnlySpan<char> text

优点:更安全、避免误修改。

4. 避免破坏零分配

以下操作要谨慎:

 
span.ToArray(); 
new string(span); 

否则优化失效。

5. 配合 ArrayPool 使用

var array = ArrayPool<byte>.Shared.Rent(1024);
var span = array.AsSpan(0, length);
优点:减少大数组分配、提高吞吐。

6. 小缓冲区用 stackalloc

Span<byte> buffer = stackalloc byte[256];

注意:不要过大(一般 < 1KB)。

7. API 设计建议

  • 输入参数:ReadOnlySpan<T> / Memory<T>
  • 输出数据:Memory<T>(避免返回 Span)

8. 注意生命周期问题(非常重要)

错误示例:

Span<byte> GetSpan()
{
    Span<byte> s = stackalloc byte[10];
    return s; // 危险
}

原因:Span 绑定栈帧,离开即失效。

常见误区

误区1:Span 能提升并发性能

事实:它只是减少分配,不涉及线程调度或锁机制

误区2:所有项目都需要 Span

现实:仅在性能瓶颈明确时使用,普通业务代码收益有限

误区3:Memory 比 Span 更慢

实际上:差距很小,在 async 场景必须使用 Memory

总结

Span<T> 与 Memory<T> 是 .NET 高性能开发的重要基石:

  • Span<T>:极致性能,适合短生命周期同步代码
  • Memory<T>:更灵活,适合异步和跨层传递
  • 两者结合:实现零拷贝 + 低GC + 高吞吐

一句话总结:能用 Span 就用 Span,需要跨生命周期就用 Memory。

评论