.NET 中的Span和Memory
✈️

.NET 中的Span和Memory

Tags
.NET
性能优化
内存管理
Published
February 26, 2025
Author

简介

.NET 中的 Span<T>Memory<T> 是 .NET Core 2.1(对应 C# 7.2)引入的高性能内存访问类型,它们的主要目的是提供零拷贝、类型安全且连续内存区域的视图(view),避免不必要的数组复制和堆分配,从而显著提升性能,尤其适合解析、序列化、网络 IO、字符串处理等场景。

为什么需要 Span<T> 和 Memory<T>?

  • 🚀 零分配:直接引用现有数据,无需复制
  • 高性能:减少 GC 压力和 CPU 缓存失效
  • 🔒 内存安全:编译器支持的引用语义验证
  • 🎯 统一接口:支持数组、本机指针、栈数据等多种数据源

场景与问题

考虑这样的场景:你需要处理一个大数组的一部分
// 传统方式 int[] largeArray = new int[1000000]; int[] subArray = new int[100]; Array.Copy(largeArray, 0, subArray, 0, 100); // 内存分配 + 数据复制 ProcessArray(subArray);
问题:
  • 每次创建新数组都会分配堆内存
  • 数据复制开销大
  • GC 压力增加
  • 对于高性能场景不可接受

Span<T> 详解

定义与特性

Span<T> 是一个值类型(struct),它是对连续内存区域的轻量级引用,大小仅为 16 字节(64 位系统)。
public readonly ref struct Span<T> { public int Length { get; } public ref T this[int index] { get; } // 其他成员... }
关键特性:
  • ✅ 值类型(struct)— 分配在栈上
  • ✅ 包含指针和长度信息
  • ✅ 支持任意内存源(栈、堆、非托管内存)
  • ❌ 不能用于异步方法(ref struct 限制)
  • ❌ 不能装箱
  • ❌ 不能作为类型参数

使用示例

// 1. 从数组创建 int[] array = { 1, 2, 3, 4, 5 }; Span<int> span = array.AsSpan(); Span<int> slice = span.Slice(1, 3); // [2, 3, 4] // 2. 从栈数据创建 Span<int> stackSpan = stackalloc int[10]; for (int i = 0; i < 10; i++) stackSpan[i] = i; // 3. 从指针创建 unsafe { fixed (int* ptr = array) { Span<int> ptrSpan = new Span<int>(ptr, array.Length); } } // 4. 切片操作 — 零分配 Span<int> part = span[1..4]; // C# 8.0+ 范围语法

Memory<T> 详解

定义与特性

Memory<T> 是一个值类型,但不是 ref struct。它对应用于存储对内存的引用,并且可以在异步方法中使用。
public readonly struct Memory<T> { public int Length { get; } public Span<T> Span { get; } public MemoryPool<T> MemoryPool { get; } // 其他成员... }
关键特性:
  • ✅ 可用于异步方法
  • ✅ 可以装箱
  • ✅ 可作为泛型参数
  • ✅ 支持 IDisposable 的生命周期管理
  • ❌ 相对于 Span<T> 有额外的堆分配(在某些情况下)

使用示例

// 1. 从数组创建 int[] array = { 1, 2, 3, 4, 5 }; Memory<int> memory = array.AsMemory(); Memory<int> slice = memory.Slice(1, 3); // 2. 异步操作 async Task ProcessAsync(Memory<int> data) { await Task.Delay(100); Span<int> span = data.Span; // 获取 Span ProcessSpan(span); } // 3. 获取可释放的内存池 using (var buffer = MemoryPool<byte>.Shared.Rent(1024)) { Memory<byte> memory = buffer.Memory; // 使用 memory }

与 Span<T> 的关系

Memory<int> memory = new int[10].AsMemory(); Span<int> span = memory.Span; // Memory → Span(隐式转换) // 反向不可以 // Span<int> span = stackalloc int[10]; // Memory<int> memory = span.AsMemory(); // ❌ 编译错误

ReadOnlySpan<T> 和 ReadOnlyMemory<T>

只读变体

.NET 同时提供了只读版本,用于表示不可修改的内存区域。
// ReadOnlySpan<T> ReadOnlySpan<char> text = "Hello".AsSpan(); // text[0] = 'A'; // ❌ 编译错误 // ReadOnlyMemory<T> ReadOnlyMemory<byte> data = Encoding.UTF8.GetBytes("Test").AsMemory();
适用场景:
  • 只读数据(字符串、常量)
  • 参数验证(确保调用者不修改数据)
  • API 设计(明确表示意图)

关键区别

特性
Span<T>
Memory<T>
ReadOnlySpan<T>
ReadOnlyMemory<T>
是否为 ref struct
✅ 是
❌ 否
✅ 是
❌ 否
可用于异步
❌ 否
✅ 是
❌ 否
✅ 是
可装箱
❌ 否
✅ 是
❌ 否
✅ 是
可作为泛型参数
❌ 否
✅ 是
❌ 否
✅ 是
内存开销
16 字节
16+ 字节
16 字节
16+ 字节
支持切片
✅ 是
✅ 是
✅ 是
✅ 是
零分配
✅ 是*
⚠️ 视情况
✅ 是*
⚠️ 视情况
  • Stack allocated 数据的 Span 通常为零分配;托管堆数据通过 AsSpan() 也是零分配

实际应用场景

场景 1:文本处理

// 解析 CSV 一行数据,无分配 void ParseCsvLine(ReadOnlySpan<char> line) { var fields = line.Split(','); foreach (var field in fields) { var trimmed = field.Trim(); // 处理字段... } } // 调用 ParseCsvLine("John,Doe,30"); // 无数组分配

场景 2:网络 I/O 优化

// 接收网络数据 public async Task ReceiveDataAsync(NetworkStream stream, Memory<byte> buffer) { int bytesRead = await stream.ReadAsync(buffer); ProcessData(buffer.Span.Slice(0, bytesRead)); } // ProcessData 无需关心数据来源,性能一致 void ProcessData(ReadOnlySpan<byte> data) { for (int i = 0; i < data.Length; i++) { // 处理每个字节 } }

场景 3:栈分配优化

// 处理小数据集,使用栈分配 void ProcessSmallArray() { Span<int> buffer = stackalloc int[256]; for (int i = 0; i < buffer.Length; i++) { buffer[i] = i * 2; } // buffer 自动释放,无 GC 压力 }

场景 4:异步数据处理

// 需要在异步方法中使用,必须用 Memory<T> async Task ProcessLargeFileAsync(string filePath) { using var file = File.OpenRead(filePath); byte[] buffer = new byte[4096]; Memory<byte> memory = buffer.AsMemory(); int bytesRead; while ((bytesRead = await file.ReadAsync(memory)) > 0) { await HandleChunkAsync(memory.Slice(0, bytesRead)); } }

前后对比

// 传统方式:每次分配新数组 void TraditionalApproach(int[] data) { for (int i = 0; i < data.Length - 10; i++) { int[] slice = new int[10]; Array.Copy(data, i, slice, 0, 10); ProcessArray(slice); } } // Span 方式:零分配切片 void SpanApproach(int[] data) { Span<int> span = data.AsSpan(); for (int i = 0; i < span.Length - 10; i++) { var slice = span.Slice(i, 10); ProcessSpan(slice); } }

最佳实践

✅ 推荐做法

  1. 使用 Span<T> 作为参数
    1. // 好:接受任意数据源 void Process(ReadOnlySpan<int> data) { }
  1. 优先使用 ReadOnlySpan<T>/ReadOnlyMemory<T>
    1. // 比 Span\<T\> 更安全,表意更明确 void ValidateInput(ReadOnlySpan<byte> input) { }
  1. 异步方法中使用 Memory<T>
    1. async Task HandleAsync(Memory<byte> buffer) { }
  1. 小缓冲区使用 stackalloc
    1. Span<byte> buffer = stackalloc byte[256];

❌ 避免的做法

  1. 不要在 ref struct 字段中存储引用
    1. // ❌ 编译错误 ref struct Container { Span<int> data; // ref struct 不能包含引用字段 }
  1. 不要在异步方法中使用 Span<T>
    1. // ❌ 编译错误 async Task BadAsync(Span<int> data) { }
  1. 小心栈溢出
    1. // ❌ 危险:分配过大 Span<byte> huge = stackalloc byte[10_000_000];

常见问题

Q1: 何时使用 Span<T> vs Memory<T>?

使用 Span<T>:
  • 同步 API
  • 不需要存储引用
  • 追求最小开销
使用 Memory<T>:
  • 异步 API
  • 需要跨方法边界传递
  • 与 IAsyncEnumerable 或任务配合

Q2: Span<T> 为什么是 ref struct?

ref struct 的限制保证了:
  • 栈数据不会逃逸到堆
  • 不会发生悬垂指针
  • 引用总是有效的

Q3: 如何与现有代码兼容?

.NET 提供了隐式转换:
int[] array = new int[10]; Span<int> span = array; // 隐式转换

总结

Span<T>Memory<T> 是现代 .NET 性能优化的核心工具:
关键点
说明
核心价值
零分配、零开销的内存操作
Span
栈数据、同步 API、最高性能
Memory
异步 API、可存储、灵活性高
ReadOnly 变体
安全性、意图清晰、接口设计
应用场景
文本处理、网络 I/O、数据处理、性能优化
掌握这两个类型,你将能够编写出更高效、更符合现代 .NET 最佳实践的代码。

参考资源