name: dotnet-performance-patterns description: “优化 .NET 分配和吞吐量。Span、ArrayPool、ref struct、sealed、stackalloc。” user-invocable: false
dotnet-performance-patterns
.NET 应用程序的性能导向架构模式。涵盖使用 Span<T> 和 Memory<T> 的零分配编码、使用 ArrayPool<T> 的缓冲区池、性能优化的结构设计(readonly struct、ref struct、in 参数)、JIT 对密封类的去虚拟化、使用 stackalloc 的堆栈分配,以及字符串处理性能。侧重于 为什么(性能原理和测量)而不是 如何(语言语法)。
版本假设: .NET 8.0+ 基准。Span<T> 和 Memory<T> 从 .NET Core 2.1+ 起可用,但此技能针对 .NET 8+ 上的现代使用模式。
范围
- 使用 Span<T> 和 Memory<T> 进行零分配编码
- 使用 ArrayPool<T> 进行缓冲区池
- 性能优化的结构设计(readonly struct、ref struct、in 参数)
- JIT 对密封类的去虚拟化
- 使用 stackalloc 进行堆栈分配
- 字符串处理性能模式
超出范围
- Span、记录、模式匹配的 C# 语言语法 – 参见 [skill:dotnet-csharp-modern-patterns]
- 编码标准和命名约定 – 参见 [skill:dotnet-csharp-coding-standards]
- 微基准测试设置和测量 – 参见 [skill:dotnet-benchmarkdotnet]
- 本地 AOT 编译和裁剪 – 参见 [skill:dotnet-native-aot]
- 序列化格式性能 – 参见 [skill:dotnet-serialization]
- 架构模式(缓存、弹性、DI) – 参见 [skill:dotnet-architecture-patterns]
交叉引用:[skill:dotnet-benchmarkdotnet] 用于测量这些模式的影响,[skill:dotnet-csharp-modern-patterns] 用于 Span/Memory 语法基础,[skill:dotnet-csharp-coding-standards] 用于密封类风格约定,[skill:dotnet-native-aot] 用于 AOT 性能特征和裁剪对模式选择的影响,[skill:dotnet-serialization] 用于序列化性能上下文。
Span<T> 和 Memory<T> 用于零分配场景
为什么 Span<T> 对性能很重要
Span<T> 提供了一种安全、边界检查的连续内存视图,无需分配。它支持对数组、字符串和堆栈内存进行切片而无需复制。语法细节请参见 [skill:dotnet-csharp-modern-patterns];本节侧重于性能原理。
零分配字符串处理
// 错误:每次调用 Substring 都会分配新字符串
public static (string Key, string Value) ParseHeader_Allocating(string header)
{
var colonIndex = header.IndexOf(':');
return (header.Substring(0, colonIndex), header.Substring(colonIndex + 1).Trim());
}
// 正确:ReadOnlySpan<char> 切片避免所有分配
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> 切片 -- 无分配
return ProcessData(data.Span); // .Span 用于同步处理
}
private int ProcessData(ReadOnlySpan<byte> data)
{
var sum = 0;
foreach (var b in data)
sum += b;
return sum;
}
ArrayPool<T> 用于缓冲区池
为什么使用缓冲区池
大数组分配(>= 85,000 字节)直接进入大对象堆(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);
// 重要:Rent 可能返回比请求更大的缓冲区。
// 始终使用 bytesRead 或请求的长度,而不是 buffer.Length。
return ProcessChunk(buffer.AsSpan(0, bytesRead));
}
finally
{
ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
// clearArray: true 清零缓冲区 -- 当缓冲区持有敏感数据时使用
}
}
常见错误
| 错误 | 影响 | 修复方法 |
|---|---|---|
使用 buffer.Length 而不是请求大小 |
处理超出实际数据的未初始化字节 | 单独跟踪请求/实际大小 |
| 忘记返回缓冲区 | 池耗尽,回退到分配 | 使用 try/finally 或 using 包装器 |
| 返回缓冲区两次 | 损坏池状态 | 返回后清空引用 |
| 不清除敏感数据 | 来自池化缓冲区的安全泄漏 | 向 Return 传递 clearArray: true |
readonly struct、ref struct 和 in 参数
readonly struct – 防御性副本消除
JIT 在通过 in、readonly 字段或 readonly 方法访问非 readonly struct 时必须进行防御性复制以防止突变。标记 struct 为 readonly 保证不可变性,消除这些副本:
// 正确:readonly 消除每次访问的防御性副本
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 知道这不会突变,无需防御性副本
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 参数调用 struct 上的方法会强制 JIT 复制整个 struct 以防止突变。对于紧密循环中的大型结构,这消除了显著开销。
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('
');
if (newlineIndex == -1)
{
Current = _remaining;
_remaining = default;
}
else
{
Current = _remaining[..newlineIndex];
_remaining = _remaining[(newlineIndex + 1)..];
}
return true;
}
}
in 参数 – 无突变的传递引用
使用 in 传递大型 readonly struct 到方法。in 修饰符传递引用(避免复制)并防止突变:
// in 参数:传递引用,无副本,不允许突变
public static double CalculateDistance(in Point3D a, in Point3D b)
=> a.DistanceTo(in b);
何时使用 in:
| 结构大小 | 建议 |
|---|---|
| <= 16 字节 | 按值传递(寄存器友好,无间接开销) |
| > 16 字节 | 使用 in 避免复制开销 |
| 任何大小,readonly struct | in 安全(无防御性副本) |
| 任何大小,非 readonly struct | 避免 in(防御性副本抵消好处) |
密封类性能原理
JIT 去虚拟化
当类被 sealed 时,JIT 可以将虚拟方法调用替换为直接调用(去虚拟化),因为没有子类覆盖可能。这支持进一步内联:
// 无密封:通过虚表进行虚拟分发
public class OpenService : IProcessor
{
public virtual int Process(int x) => x * 2;
}
// 有密封:JIT 去虚拟化 + 内联 Process 调用
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 字节 -- 在安全限制内
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 运行时库中使用,是处理小型和大型输入的方法的推荐方法。
字符串内部化和字符串比较性能
字符串比较性能
序数比较比文化感知比较快得多,因为它们避免 Unicode 规范化:
// 快:序数比较(逐字节)
bool isMatch = str.Equals("expected", StringComparison.Ordinal);
bool containsKey = dict.ContainsKey(key); // Dictionary<string, T> 默认使用序数
// 快:忽略大小写序数(无文化开销)
bool isMatchIgnoreCase = str.Equals("expected", StringComparison.OrdinalIgnoreCase);
// 慢:文化感知比较(Unicode 规范化、语言规则)
bool isMatchCulture = str.Equals("expected", StringComparison.CurrentCulture);
默认指导: 对于内部标识符、字典键、文件路径和协议字符串,使用 StringComparison.Ordinal 或 StringComparison.OrdinalIgnoreCase。为面向用户的文本排序和显示保留文化感知比较。
字符串内部化
CLR 自动内部化编译时字符串字面量。string.Intern() 可以减少频繁重复的运行时字符串的内存使用:
// 内部化频繁重复的运行时字符串以共享单个实例
var normalized = string.Intern(headerName.ToLowerInvariant());
注意: 内部化字符串永远不会被垃圾回收。仅内部化来自有界、已知集合的字符串(如 HTTP 头部、XML 元素名称)。切勿内部化用户输入或无界数据。
高效字符串构建
| 场景 | 推荐方法 | 原因 |
|---|---|---|
| 2-3 次拼接 | 字符串插值 $"{a}{b}" |
编译器优化为 string.Concat |
| 循环拼接 | StringBuilder |
避免二次分配 |
| 已知固定部分 | string.Create |
单次分配,基于 Span 的写入 |
| 高吞吐量格式化 | Span<char> + TryFormat |
零分配格式化 |
// string.Create 用于单次分配构建
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]),验证模式是裁剪安全的
- 用类似生产数据验证 – 合成基准可能错过真实世界分配模式
- 记录权衡 – 每个优化都以可读性或灵活性换取速度;记录测量的增益
代理注意事项
- 优化前测量 – 切勿在没有基准显示分配或延迟问题的情况下应用 Span/ArrayPool/stackalloc。过早优化会产生不可读代码而无明显益处。
- 不要将 stackalloc 与来自不可信输入的变量大小一起使用 – 栈溢出会导致进程崩溃且无异常处理。始终验证边界或使用混合 stackalloc/ArrayPool 模式。
- 当值类型不可变时,始终标记为
readonly struct– 没有readonly,JIT 会在每次in参数访问和readonly字段访问时生成防御性副本,悄无声息地抵消使用结构的性能益处。 - 在 finally 块中返回租借的 ArrayPool 缓冲区 – 忘记返回会使池耗尽并导致回退分配,抵消益处。
- 对于内部比较使用
StringComparison.Ordinal– 省略比较参数默认为文化感知比较,这较慢且对技术字符串(如文件路径、标识符)产生意外结果。 - 密封类仅在 JIT 能看到具体类型时帮助性能 – 如果对象在非可去虚拟化调用点通过接口变量访问,密封无益。用
[DisassemblyDiagnoser]验证。 - 不要重新教授语言语法 – 引用 [skill:dotnet-csharp-modern-patterns] 了解 Span/Memory 语法细节。此技能侧重于何时以及为什么使用这些模式来提升性能。
知识来源
此技能中的性能模式基于以下来源的指导:
- Stephen Toub – .NET 性能博客系列 (devblogs.microsoft.com/dotnet/author/toub)。关于 Span<T>、ValueTask、ArrayPool、异步内部和运行时性能特征的权威来源。
- Stephen Cleary – 异步最佳实践和并发集合指导。Concurrency in C# Cookbook 作者。
- Nick Chapsas – 现代 .NET 性能模式和基准测试方法论。
这些来源启发了上述模式和原理。此技能不代表或代言任何个人。