第 13 章:高性能 .NET

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

深入理解 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 面试要点

  1. Span<T> 和 Memory<T> 的区别?

    • Span 是 ref struct,只能在栈上,不能用于 async 方法
    • Memory 是普通 struct,可以存储在堆上,可以用于 async 方法
  2. 为什么 Span<T> 是 ref struct?

    • 保证只在栈上存在,内部的 ref 指针不会被 GC 移动后失效
  3. ArrayPool 的工作原理?

    • 维护多个桶(bucket),每个桶存储特定大小范围的数组
    • Rent 时从桶中取出,Return 时放回桶中复用
  4. stackalloc 的限制?

    • 栈空间有限(默认 1MB),不能分配太大
    • 不能在 async 方法中使用(状态机在堆上)
  5. 如何实现零分配的字符串处理?

    • 使用 ReadOnlySpan<char> 切片代替 Substring
    • 使用 string.Create 代替字符串拼接
    • 使用 TryFormat 代替 ToString

练习

  1. 使用 Span<T> 实现一个零分配的 CSV 解析器
  2. 使用 ArrayPool 优化一个频繁分配大数组的场景,用 BenchmarkDotNet 对比性能
  3. 实现一个自定义的 ObjectPool,支持最大容量限制和对象过期
  4. 使用 BenchmarkDotNet 对比 string.Concat、StringBuilder、string.Create 的性能

← 上一章:依赖注入的原理与实现 | 下一章:.NET 面试高频题精讲 →

评论

登录 后发表评论

暂无评论