关于 dotnet-performance-patterns
面向 .NET 8 及以上应用的性能模式指南,适用于希望减少分配和改善热点路径的开发者。它解释 Span、缓冲区池、结构体和字符串处理等做法的性能原因,并强调先测量再优化。
主要功能
- 01Span 与 Memory 的低分配处理
- 02使用 ArrayPool 复用缓冲区
- 03结构体与 in 参数的性能考量
- 04sealed 类与 JIT 去虚拟化
- 05stackalloc 与安全边界
- 06字符串比较及构建优化
适用场景
- 01降低高吞吐解析中的 GC 压力
- 02减少大型数据缓冲区反复分配
- 03优化热点路径中的结构体传递
- 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]。
性能影响
去虚拟化 + 内联可消除:
- 虚函数表查找 —— 通过间接内存访问查找方法指针
- 调用开销 —— 实际的间接调用指令
- 内联屏障 —— 虚调用无法内联;密封方法可以内联
在紧密循环和热路径中,累积效果可以测量出来。对于不为扩展而设计的框架/库类型,请始终优先使用 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 _);
});
}
性能测量清单
应用任何优化模式之前,请先测量。没有数据支撑的过早优化会导致代码复杂,却没有可衡量的收益。
- 找出热点路径 —— 使用 [skill:dotnet-benchmarkdotnet] 建立基准
- 测量分配量 —— 启用
[MemoryDiagnoser]并检查Allocated列 - 一次应用一种模式 —— 每次只改一处,重新测量并与基准比较
- 检查 AOT 的影响 —— 如果目标是 Native AOT([skill:dotnet-native-aot]),请确认这些模式支持裁剪
- 使用接近生产环境的数据验证 —— 合成基准测试可能无法反映真实世界的分配模式
- 记录权衡 —— 每项优化都是以牺牲可读性或灵活性换取速度;记录测得的收益
Agent 注意事项
- 优化前先测量 —— 除非基准测试显示存在分配或延迟问题,否则不要使用 Span/ArrayPool/stackalloc。过早优化会产生难以阅读的代码,却没有可衡量的收益。
- 不要对来自不可信输入的可变大小使用 stackalloc —— 栈溢出会导致进程崩溃,且没有异常处理程序可以捕获。务必验证边界,或使用 stackalloc/ArrayPool 混合模式。
- 不可变值类型务必标记为
readonly struct—— 如果没有readonly,JIT 会在每次访问in参数和readonly字段时生成防御性副本,悄无声息地抵消使用结构体带来的性能优势。 - 在 finally 块中归还租用的 ArrayPool 缓冲区 —— 忘记归还会耗尽池中的缓冲区,并导致回退分配,抵消其优势。
- 内部比较使用
StringComparison.Ordinal—— 省略比较参数时,默认会进行遵循区域性的比较,这种比较速度较慢,且对技术字符串(文件路径、标识符)会产生意外结果。 - 只有当 JIT 能看到具体类型时,密封类才有助于提升性能 —— 如果对象是通过接口变量访问的,且调用点无法去虚拟化,那么密封类不会带来任何好处。请使用
[DisassemblyDiagnoser]验证。 - 不要重复讲解语言语法 —— 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 性能模式和基准测试方法。
以上来源为本文介绍的模式和理由提供参考。本技能并不声称代表任何个人,也不代表任何个人发言。