第 13 章:高性能 .NET
jerry北京市2026年4月22日C# 7 次阅读 约 23 分钟

深入理解 Span<T> 和 Memory<T> 的原理、stackalloc 栈分配、ArrayPool 和 ObjectPool 对象池、ref 返回值、BenchmarkDotNet 性能测试,以及零分配编程模式。
13.1 Span<T> 深入
Span<T> 是一个 ref struct,表示一段连续内存的视图,不产生堆分配:
// Span 可以指向不同类型的内存
int[] array = [1, 2, 3, 4, 5];
Span<int> fromArray = array.AsSpan(); // 托管数组
Span<int> slice = array.AsSpan(1, 3); // 切片:[2, 3, 4]
Span<int> fromStack = stackalloc int[5]; // 栈内存
Span 的内部结构
// Span<T> 的简化定义
ref struct Span<T>
{
internal ref T _reference; // 指向内存起始位置的托管指针
private int _length; // 长度
public ref T this[int index] => ref Unsafe.Add(ref _reference, index);
}
Span<int> 指向数组的切片:
┌───┬───┬───┬───┬───┐
数组: │ 1 │ 2 │ 3 │ 4 │ 5 │
└───┴───┴───┴───┴───┘
↑
Span._reference ──────────┘
Span._length = 3 [2, 3, 4]
字符串处理优化
// 传统方式:每次 Substring 都分配新字符串
string ParseTraditional(string input)
{
int start = input.IndexOf('[');
int end = input.IndexOf(']');
return input.Substring(start + 1, end - start - 1); // 堆分配!
}
// Span 方式:零分配
ReadOnlySpan<char> ParseOptimized(ReadOnlySpan<char> input)
{
int start = input.IndexOf('[');
int end = input.IndexOf(']');
return input.Slice(start + 1, end - start - 1); // 零分配!
}
// 使用
string data = "Hello [World] !";
var result = ParseOptimized(data.AsSpan()); // 零分配
高效的字符串分割
// string.Split 会分配数组和多个字符串
// 使用 Span 实现零分配分割
static void ProcessCsv(ReadOnlySpan<char> line)
{
while (!line.IsEmpty)
{
int comma = line.IndexOf(',');
ReadOnlySpan<char> field = comma == -1 ? line : line[..comma];
ProcessField(field); // 处理字段,零分配
line = comma == -1 ? ReadOnlySpan<char>.Empty : line[(comma + 1)..];
}
}
// .NET 8+ 更简洁的方式
static void ProcessCsvModern(ReadOnlySpan<char> line)
{
foreach (var range in line.Split(','))
{
ReadOnlySpan<char> field = line[range];
ProcessField(field);
}
}
13.2 Memory<T>
Memory<T> 是 Span<T> 的堆友好版本,可以存储在类字段中、用于 async 方法:
// Span<T> 是 ref struct,不能用在这些场景
class DataProcessor
{
// private Span<byte> _data; // ❌ 编译错误:ref struct 不能作为字段
private Memory<byte> _data; // ✅ Memory<T> 可以
public async Task ProcessAsync()
{
// Span<byte> span = _data; // ❌ 不能在 async 方法中使用 Span
Memory<byte> memory = _data; // ✅ Memory<T> 可以
await Task.Delay(100);
// 需要 Span 时,在同步代码块中获取
Span<byte> span = memory.Span;
ProcessSpan(span);
}
}
| 特性 | Span<T> | Memory<T> |
|---|---|---|
| 类型 | ref struct | struct |
| 堆存储 | ❌ | ✅ |
| async 方法 | ❌ | ✅ |
| 性能 | 更快(直接指针) | 略慢(需要 .Span 转换) |
| 使用场景 | 同步的高性能代码 | 需要跨 await 的场景 |
13.3 stackalloc
在栈上分配内存,避免堆分配和 GC 压力:
// 栈上分配小数组
Span<int> buffer = stackalloc int[64];
for (int i = 0; i < buffer.Length; i++)
buffer[i] = i * 2;
// 条件分配:小数据用栈,大数据用堆
int size = GetRequiredSize();
Span<byte> span = size <= 256
? stackalloc byte[size]
: new byte[size];
// 实际应用:高效的字符串格式化
static string FormatNumber(int value)
{
Span<char> buffer = stackalloc char[16];
if (value.TryFormat(buffer, out int written))
return new string(buffer[..written]);
return value.ToString();
}
注意事项:
- 栈空间有限(默认 1MB),不要分配太大
- 只在方法内使用,不能返回 stackalloc 的引用
- 建议限制在 256-1024 字节以内
13.4 ArrayPool<T>
复用数组,避免频繁分配和 GC:
// 共享池
var pool = ArrayPool<byte>.Shared;
byte[] buffer = pool.Rent(1024); // 租借(实际可能大于 1024)
try
{
int bytesRead = await stream.ReadAsync(buffer.AsMemory(0, 1024));
ProcessData(buffer.AsSpan(0, bytesRead));
}
finally
{
pool.Return(buffer, clearArray: true); // 归还(clearArray 清零敏感数据)
}
// 自定义池
var customPool = ArrayPool<int>.Create(
maxArrayLength: 1024 * 1024, // 最大数组长度
maxArraysPerBucket: 50); // 每个桶的最大数组数
MemoryPool<T>
// MemoryPool 返回 IMemoryOwner<T>,支持 using 自动归还
using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(1024);
Memory<byte> memory = owner.Memory;
// 离开 using 作用域时自动归还
13.5 ObjectPool<T>
复用任意对象(不仅是数组):
// Microsoft.Extensions.ObjectPool
using Microsoft.Extensions.ObjectPool;
// 创建 StringBuilder 对象池
var policy = new StringBuilderPooledObjectPolicy();
var pool = new DefaultObjectPool<StringBuilder>(policy);
// 使用
var sb = pool.Get();
try
{
sb.Append("Hello");
sb.Append(" World");
var result = sb.ToString();
}
finally
{
pool.Return(sb); // 归还时自动清空
}
// 自定义对象池策略
class ExpensiveObjectPolicy : PooledObjectPolicy<ExpensiveObject>
{
public override ExpensiveObject Create() => new ExpensiveObject();
public override bool Return(ExpensiveObject obj)
{
obj.Reset(); // 重置状态
return true; // true 表示可以复用
}
}
13.6 ref 返回值与 ref 局部变量
避免大型值类型的复制:
struct LargeStruct
{
public long A, B, C, D, E, F, G, H; // 64 bytes
}
class Container
{
private LargeStruct[] _items = new LargeStruct[100];
// ref 返回:返回引用而不是副本
public ref LargeStruct GetItem(int index) => ref _items[index];
// ref readonly 返回:只读引用
public ref readonly LargeStruct GetItemReadOnly(int index) => ref _items[index];
}
var container = new Container();
// ref 局部变量:直接修改原始数据
ref LargeStruct item = ref container.GetItem(0);
item.A = 42; // 直接修改 _items[0],无复制
// ref readonly:只读访问
ref readonly LargeStruct readOnly = ref container.GetItemReadOnly(0);
// readOnly.A = 42; // 编译错误:只读
Console.WriteLine(readOnly.A); // 42,无复制
in 参数
// in 参数:按引用传递,但不能修改(避免大 struct 的复制)
void Process(in LargeStruct data)
{
Console.WriteLine(data.A); // 按引用读取,无复制
// data.A = 42; // 编译错误:不能修改 in 参数
}
13.7 BenchmarkDotNet
.NET 性能测试的标准工具:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser] // 显示内存分配
[SimpleJob(warmupCount: 3, iterationCount: 10)]
public class StringBenchmark
{
private const string Data = "Hello [World] Test";
[Benchmark(Baseline = true)]
public string Substring()
{
int start = Data.IndexOf('[') + 1;
int end = Data.IndexOf(']');
return Data.Substring(start, end - start);
}
[Benchmark]
public ReadOnlySpan<char> SpanSlice()
{
ReadOnlySpan<char> span = Data.AsSpan();
int start = span.IndexOf('[') + 1;
int end = span.IndexOf(']');
return span.Slice(start, end - start);
}
}
// 运行
BenchmarkRunner.Run<StringBenchmark>();
输出示例:
| Method | Mean | Allocated |
|----------- |----------:|-----------:|
| Substring | 15.23 ns | 32 B |
| SpanSlice | 5.67 ns | 0 B | ← 零分配
常用特性
[MemoryDiagnoser] // 内存分配统计
[DisassemblyDiagnoser] // 查看生成的汇编代码
[ThreadingDiagnoser] // 线程统计
[Params(10, 100, 1000)] // 参数化测试
public int Size { get; set; }
[GlobalSetup] // 全局初始化
public void Setup() { }
[IterationSetup] // 每次迭代前初始化
public void IterSetup() { }
13.8 零分配模式总结
// 1. 使用 Span<T> 代替数组切片
ReadOnlySpan<char> slice = "hello world".AsSpan(6); // 零分配
// 2. 使用 stackalloc 代替小数组
Span<byte> buffer = stackalloc byte[128];
// 3. 使用 ArrayPool 代替 new 数组
var pool = ArrayPool<byte>.Shared;
var buf = pool.Rent(1024);
// 4. 使用 ValueTask 代替 Task(同步完成时)
ValueTask<int> GetAsync() => new ValueTask<int>(42); // 零分配
// 5. 使用 struct 代替 class(小对象)
readonly record struct Point(int X, int Y);
// 6. 使用 string.Create 代替字符串拼接
string result = string.Create(10, 42, (span, value) =>
{
value.TryFormat(span, out _);
});
// 7. 使用 ISpanFormattable(.NET 6+)
Span<char> dest = stackalloc char[32];
if (DateTime.Now.TryFormat(dest, out int written, "yyyy-MM-dd"))
{
ReadOnlySpan<char> formatted = dest[..written];
}
// 8. 使用 SearchValues(.NET 8)
private static readonly SearchValues<char> s_vowels =
SearchValues.Create("aeiouAEIOU");
int index = "hello".AsSpan().IndexOfAny(s_vowels); // 高度优化的搜索
13.9 面试要点
-
Span<T> 和 Memory<T> 的区别?
- Span 是 ref struct,只能在栈上,不能用于 async 方法
- Memory 是普通 struct,可以存储在堆上,可以用于 async 方法
-
为什么 Span<T> 是 ref struct?
- 保证只在栈上存在,内部的 ref 指针不会被 GC 移动后失效
-
ArrayPool 的工作原理?
- 维护多个桶(bucket),每个桶存储特定大小范围的数组
- Rent 时从桶中取出,Return 时放回桶中复用
-
stackalloc 的限制?
- 栈空间有限(默认 1MB),不能分配太大
- 不能在 async 方法中使用(状态机在堆上)
-
如何实现零分配的字符串处理?
- 使用 ReadOnlySpan<char> 切片代替 Substring
- 使用 string.Create 代替字符串拼接
- 使用 TryFormat 代替 ToString
练习
- 使用 Span<T> 实现一个零分配的 CSV 解析器
- 使用 ArrayPool 优化一个频繁分配大数组的场景,用 BenchmarkDotNet 对比性能
- 实现一个自定义的 ObjectPool,支持最大容量限制和对象过期
- 使用 BenchmarkDotNet 对比 string.Concat、StringBuilder、string.Create 的性能
评论
登录 后发表评论
暂无评论