.NET性能模式 dotnet-performance-patterns

此技能专注于 .NET 应用程序的性能优化架构模式,包括零分配编码、缓冲区池、结构设计、密封类优化、堆栈分配和字符串处理性能等关键词,旨在提升应用吞吐量、减少GC压力并优化内存使用,适用于后端开发和架构设计场景。

架构设计 0 次安装 0 次浏览 更新于 3/6/2026

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 在通过 inreadonly 字段或 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] 了解默认使用密封类的项目约定。

性能影响

去虚拟化 + 内联消除了:

  1. 虚表查找 – 间接内存访问以查找方法指针
  2. 调用开销 – 实际的间接调用指令
  3. 内联障碍 – 虚拟调用不能内联;密封方法可以

在紧密循环和热路径中,累积效应是可测量的。对于未设计为扩展的框架/库类型,始终首选 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.OrdinalStringComparison.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 _);
    });
}

性能测量检查清单

在应用任何优化模式之前,先测量。没有数据的过早优化会导致复杂代码而无明显益处。

  1. 识别热路径 – 使用 [skill:dotnet-benchmarkdotnet] 建立基线
  2. 测量分配 – 启用 [MemoryDiagnoser] 并检查 Allocated
  3. 一次应用一个模式 – 更改一件事,重新测量,与基线比较
  4. 检查 AOT 影响 – 如果针对 Native AOT ([skill:dotnet-native-aot]),验证模式是裁剪安全的
  5. 用类似生产数据验证 – 合成基准可能错过真实世界分配模式
  6. 记录权衡 – 每个优化都以可读性或灵活性换取速度;记录测量的增益

代理注意事项

  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. 不要重新教授语言语法 – 引用 [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 性能模式和基准测试方法论。

这些来源启发了上述模式和原理。此技能不代表或代言任何个人。