mcpField
D
dotnet-performance-patterns

dotnet-performance-patterns

github.com/wshaddix开发86117 次安装35 周前更新
代码仓库

关于 dotnet-performance-patterns

面向 .NET 8 及以上应用的性能模式指南,适用于希望减少分配和改善热点路径的开发者。它解释 Span、缓冲区池、结构体和字符串处理等做法的性能原因,并强调先测量再优化。

主要功能

  1. 01Span 与 Memory 的低分配处理
  2. 02使用 ArrayPool 复用缓冲区
  3. 03结构体与 in 参数的性能考量
  4. 04sealed 类与 JIT 去虚拟化
  5. 05stackalloc 与安全边界
  6. 06字符串比较及构建优化

适用场景

  1. 01降低高吞吐解析中的 GC 压力
  2. 02减少大型数据缓冲区反复分配
  3. 03优化热点路径中的结构体传递
  4. 04在基准测量后评估优化效果

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-performance-patterns

安装后在 Claude Code 中输入/dotnet-performance-patterns确认已加载。

详细介绍

dotnet-performance-patterns

面向性能的 .NET 应用架构模式。涵盖使用 Span<T> 和 Memory<T> 实现零分配编码、使用 ArrayPool<T> 进行缓冲区池化、面向性能的结构体设计(readonly struct、ref struct、in 参数)、JIT 对 sealed class 的去虚拟化、使用 stackalloc 进行基于堆栈的分配,以及字符串处理性能。重点关注为什么(性能依据和测量),而不是如何做(语言语法)。

**版本假设:**以 .NET 8.0+ 为基准。Span<T> 和 Memory<T> 从 .NET Core 2.1+ 起即可使用,但此技能针对 .NET 8+ 的现代用法模式。

**不在范围内:**Span、记录、模式匹配和集合表达式的 C# 语言语法——请参阅 [skill:dotnet-csharp-modern-patterns]。编码标准和命名约定(包括 sealed class 风格指南)——请参阅 [skill:dotnet-csharp-coding-standards]。微基准测试的设置和测量由本 Epic 的配套技能负责——请参阅 [skill:dotnet-benchmarkdotnet]。Native AOT 编译流程和修剪——请参阅 [skill:dotnet-native-aot]。序列化格式的性能权衡——请参阅 [skill:dotnet-serialization]。架构模式(缓存、弹性、DI)——请参阅 [skill:dotnet-architecture-patterns]。EF Core 查询优化——请参阅 [skill:dotnet-efcore-patterns]。

交叉引用:[skill:dotnet-benchmarkdotnet] 用于测量这些模式的影响,[skill:dotnet-csharp-modern-patterns] 用于了解 Span/Memory 语法基础,[skill:dotnet-csharp-coding-standards] 用于了解 sealed class 风格约定,[skill:dotnet-native-aot] 用于了解 AOT 性能特征以及修剪对模式选择的影响,[skill:dotnet-serialization] 用于了解序列化性能背景。


用于零分配场景的 Span<T> 和 Memory<T>

Span<T> 对性能的重要性

Span<T> 提供对连续内存的安全、带边界检查的视图,且无需分配。它支持对数组、字符串和堆栈内存进行切片,而无需复制。语法详情请参阅 [skill:dotnet-csharp-modern-patterns];本节重点介绍性能依据。

零分配字符串处理
// BAD: Substring allocates a new string on each call
public static (string Key, string Value) ParseHeader_Allocating(string header)
{
    var colonIndex = header.IndexOf(':');
    return (header.Substring(0, colonIndex), header.Substring(colonIndex + 1).Trim());
}

// GOOD: ReadOnlySpan<char> slicing avoids all allocations
public static (ReadOnlySpan<char> Key, ReadOnlySpan<char> Value) ParseHeader_ZeroAlloc(
    ReadOnlySpan<char> header)
{
    var colonIndex = header.IndexOf(':');
    return (header[..colonIndex], header[(colonIndex + 1)..].Trim());
}

性能影响:对于高吞吐量解析(HTTP 标头、日志行、CSV 行),基于 Span 的解析可完全消除 GC 压力。使用 [skill:dotnet-benchmarkdotnet] 中的 [MemoryDiagnoser] 进行测量——Allocated 列应显示 0 B。

用于异步和存储场景的 Memory<T>

Span<T> 不能用于异步方法,也不能存储在堆上(它是 ref struct)。在以下情况下,请使用 Memory<T>:

  • 将缓冲区传递给异步 I/O 方法
  • 在字段或集合中存储切片引用
  • 从方法返回一段内存,以便稍后使用
public async Task<int> ReadAndProcessAsync(Stream stream, Memory<byte> buffer)
{
    var bytesRead = await stream.ReadAsync(buffer);
    var data = buffer[..bytesRead]; // Memory<T> slicing -- no allocation
    return ProcessData(data.Span);  // .Span for synchronous processing
}

private int ProcessData(ReadOnlySpan<byte> data)
{
    var sum = 0;
    foreach (var b in data)
        sum += b;
    return sum;
}

用于缓冲区池化的 ArrayPool<T>

为什么要池化缓冲区

大型数组分配(>= 85,000 字节)会直接进入大型对象堆(LOH),而 LOH 只在 Gen 2 GC 时回收,开销很大且会导致暂停。即使是较小的数组,在热点路径中也会增加 GC 压力。ArrayPool<T> 通过租用和归还缓冲区来避免重复分配。

使用模式
using System.Buffers;

public int ProcessLargeData(Stream source)
{
    var buffer = ArrayPool<byte>.Shared.Rent(minimumLength: 81920);
    try
    {
        var bytesRead = source.Read(buffer, 0, buffer.Length);
        // IMPORTANT: Rent may return a larger buffer than requested.
        // Always use bytesRead or the requested length, never buffer.Length.
        return ProcessChunk(buffer.AsSpan(0, bytesRead));
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
        // clearArray: true zeroes the buffer -- use when buffer held sensitive data
    }
}
常见错误
错误影响修正方法
使用 buffer.Length 而不是请求的大小处理实际数据之外未初始化的字节分别跟踪请求大小和实际大小
忘记归还缓冲区池耗尽,转而进行分配使用 try/finally 或 using 包装器
重复归还缓冲区破坏池状态归还后将引用置为 null
不清除敏感数据池化缓冲区导致数据泄露对 Return 传入 clearArray: true

readonly struct、ref struct 和 in 参数

readonly struct —— 消除防御性复制

通过 in、readonly 字段或 readonly 方法访问非只读结构体时,JIT 必须进行防御性复制,以防止修改。将结构体标记为 readonly 可确保其不可变,从而消除这些复制:

// GOOD: readonly eliminates defensive copies on every access
public readonly struct Point3D
{
    public double X { get; }
    public double Y { get; }
    public double Z { get; }

    public Point3D(double x, double y, double z) => (X, Y, Z) = (x, y, z);

    // readonly struct: JIT knows this cannot mutate, no defensive copy needed
    public double DistanceTo(in Point3D other)
    {
        var dx = X - other.X;
        var dy = Y - other.Y;
        var dz = Z - other.Z;
        return Math.Sqrt(dx * dx + dy * dy + dz * dz);
    }
}

如果没有 readonly,通过 in 参数调用结构体上的方法会迫使 JIT 复制整个结构体,以防止修改。对于紧密循环中的大型结构体,这可以消除显著的开销。

ref struct —— 仅限栈的类型

ref struct 类型只能分配在栈上。它们不能装箱、存储在字段中,也不能用于异步方法。这使得安全封装 Span<T> 成为可能:

public ref struct SpanLineEnumerator
{
    private ReadOnlySpan<char> _remaining;

    public SpanLineEnumerator(ReadOnlySpan<char> text) => _remaining = text;

    public ReadOnlySpan<char> Current { get; private set; }

    public bool MoveNext()
    {
        if (_remaining.IsEmpty)
            return false;

        var newlineIndex = _remaining.IndexOf('\n');
        if (newlineIndex == -1)
        {
            Current = _remaining;
            _remaining = default;
        }
        else
        {
            Current = _remaining[..newlineIndex];
            _remaining = _remaining[(newlineIndex + 1)..];
        }
        return true;
    }
}
in 参数 —— 按引用传递且不可修改

对于传递给方法的大型只读结构体,请使用 in。in 修饰符按引用传递(避免复制)并阻止修改:

// in parameter: pass by reference, no copy, no mutation allowed
public static double CalculateDistance(in Point3D a, in Point3D b)
    => a.DistanceTo(in b);

何时使用 in:

结构体大小建议
<= 16 字节按值传递(适合寄存器,不产生间接访问开销)
> 16 字节使用 in 避免复制开销
任意大小,只读结构体使用 in 是安全的(不会产生防御性复制)
任意大小,非只读结构体避免使用 in(防御性复制会抵消其优势)

密封类的性能依据

JIT 去虚拟化

当类为 sealed 时,JIT 可以将虚方法调用替换为直接调用(去虚拟化),因为不可能有子类重写该方法。这也能进一步实现内联:

// Without sealed: virtual dispatch through vtable
public class OpenService : IProcessor
{
    public virtual int Process(int x) => x * 2;
}

// With sealed: JIT devirtualizes + inlines Process call
public sealed class SealedService : IProcessor
{
    public int Process(int x) => x * 2;
}

public interface IProcessor { int Process(int x); }

使用 [skill:dotnet-benchmarkdotnet] 中的 [DisassemblyDiagnoser] 验证去虚拟化。有关项目中默认使用密封类的约定,请参阅 [skill:dotnet-csharp-coding-standards]。

性能影响

去虚拟化 + 内联可消除:

  1. 虚函数表查找 —— 通过间接内存访问查找方法指针
  2. 调用开销 —— 实际的间接调用指令
  3. 内联屏障 —— 虚调用无法内联;密封方法可以内联

在紧密循环和热路径中,累积效果可以测量出来。对于不为扩展而设计的框架/库类型,请始终优先使用 sealed。


使用 stackalloc 进行小型栈分配

何时使用 stackalloc

stackalloc 在栈上分配内存,完全避免 GC。在热路径中用于小型、固定大小的缓冲区:

public static string FormatGuid(Guid guid)
{
    // 68 bytes on the stack -- well within safe limits
    Span<char> buffer = stackalloc char[68];
    guid.TryFormat(buffer, out var charsWritten, "D");
    return new string(buffer[..charsWritten]);
}
安全指南
指南原因
保持分配较小(通常 < 1 KB,绝对最大值 < 4 KB)栈空间有限(Windows 上默认约为 1 MB);溢出会导致进程崩溃
仅使用常量或有界大小运行时可变大小可能因恶意/意外输入导致栈溢出
优先使用 Span<T> 赋值,而非原始指针Span 提供边界检查;原始指针没有
对大型/可变大小使用 ArrayPool 作为后备方案妥善处理超出栈预算的情况
混合模式:stackalloc 与 ArrayPool 后备方案
public static string ProcessData(ReadOnlySpan<byte> input)
{
    const int stackThreshold = 256;
    char[]? rented = null;

    Span<char> buffer = input.Length <= stackThreshold
        ? stackalloc char[stackThreshold]
        : (rented = ArrayPool<char>.Shared.Rent(input.Length));

    try
    {
        var written = Encoding.UTF8.GetChars(input, buffer);
        return new string(buffer[..written]);
    }
    finally
    {
        if (rented is not null)
            ArrayPool<char>.Shared.Return(rented);
    }
}

此模式广泛用于 .NET 运行时库,是同时处理小型和大型输入的方法的推荐做法。


字符串驻留与 StringComparison 性能

字符串比较性能

序号比较明显快于区域性相关比较,因为它们无需进行 Unicode 规范化:

// FAST: ordinal comparison (byte-by-byte)
bool isMatch = str.Equals("expected", StringComparison.Ordinal);
bool containsKey = dict.ContainsKey(key); // Dictionary<string, T> uses ordinal by default

// FAST: case-insensitive ordinal (no culture overhead)
bool isMatchIgnoreCase = str.Equals("expected", StringComparison.OrdinalIgnoreCase);

// SLOW: culture-aware comparison (Unicode normalization, linguistic rules)
bool isMatchCulture = str.Equals("expected", StringComparison.CurrentCulture);

默认建议: 对内部标识符、字典键、文件路径和协议字符串使用 StringComparison.Ordinal 或 StringComparison.OrdinalIgnoreCase。仅在对用户可见文本进行排序和显示时使用区域性相关比较。

字符串驻留

CLR 会自动驻留编译时字符串字面量。string.Intern() 可以减少频繁重复的运行时字符串所占用的内存:

// Intern frequently-repeated runtime strings to share a single instance
var normalized = string.Intern(headerName.ToLowerInvariant());

注意: 驻留的字符串永远不会被垃圾回收。仅对有界且已知的集合(HTTP 标头、XML 元素名称)中的字符串进行驻留。绝不要驻留用户输入或无界数据。

高效构建字符串
场景推荐方法原因
2-3 次拼接字符串插值 $"{a}{b}"编译器会优化为 string.Concat
循环拼接StringBuilder避免二次方级别的内存分配
已知固定部分string.Create单次分配,基于 Span 写入
高吞吐量格式化Span<char> + TryFormat格式化时零分配
// string.Create for single-allocation building
public static string FormatId(int category, int item)
{
    return string.Create(11, (category, item), static (span, state) =>
    {
        state.category.TryFormat(span, out var catWritten);
        span[catWritten] = '-';
        state.item.TryFormat(span[(catWritten + 1)..], out _);
    });
}

性能测量清单

应用任何优化模式之前,请先测量。没有数据支撑的过早优化会导致代码复杂,却没有可衡量的收益。

  1. 找出热点路径 —— 使用 [skill:dotnet-benchmarkdotnet] 建立基准
  2. 测量分配量 —— 启用 [MemoryDiagnoser] 并检查 Allocated 列
  3. 一次应用一种模式 —— 每次只改一处,重新测量并与基准比较
  4. 检查 AOT 的影响 —— 如果目标是 Native AOT([skill:dotnet-native-aot]),请确认这些模式支持裁剪
  5. 使用接近生产环境的数据验证 —— 合成基准测试可能无法反映真实世界的分配模式
  6. 记录权衡 —— 每项优化都是以牺牲可读性或灵活性换取速度;记录测得的收益

Agent 注意事项

  1. 优化前先测量 —— 除非基准测试显示存在分配或延迟问题,否则不要使用 Span/ArrayPool/stackalloc。过早优化会产生难以阅读的代码,却没有可衡量的收益。
  2. 不要对来自不可信输入的可变大小使用 stackalloc —— 栈溢出会导致进程崩溃,且没有异常处理程序可以捕获。务必验证边界,或使用 stackalloc/ArrayPool 混合模式。
  3. 不可变值类型务必标记为 readonly struct —— 如果没有 readonly,JIT 会在每次访问 in 参数和 readonly 字段时生成防御性副本,悄无声息地抵消使用结构体带来的性能优势。
  4. 在 finally 块中归还租用的 ArrayPool 缓冲区 —— 忘记归还会耗尽池中的缓冲区,并导致回退分配,抵消其优势。
  5. 内部比较使用 StringComparison.Ordinal —— 省略比较参数时,默认会进行遵循区域性的比较,这种比较速度较慢,且对技术字符串(文件路径、标识符)会产生意外结果。
  6. 只有当 JIT 能看到具体类型时,密封类才有助于提升性能 —— 如果对象是通过接口变量访问的,且调用点无法去虚拟化,那么密封类不会带来任何好处。请使用 [DisassemblyDiagnoser] 验证。
  7. 不要重复讲解语言语法 —— Span/Memory 的语法细节请参考 [skill:dotnet-csharp-modern-patterns]。本技能侧重于何时以及为何使用这些性能模式。

知识来源

本技能中的性能模式基于以下指导:

  • Stephen Toub —— .NET 性能博客系列(devblogs.microsoft.com/dotnet/author/toub)。关于 Span<T>、ValueTask、ArrayPool、async 内部机制和运行时性能特征的权威来源。
  • Stephen Cleary —— async 最佳实践和并发集合指南。《Concurrency in C# Cookbook》的作者。
  • Nick Chapsas —— 现代 .NET 性能模式和基准测试方法。

以上来源为本文介绍的模式和理由提供参考。本技能并不声称代表任何个人,也不代表任何个人发言。

评论

支持 Markdown