写在前面
这是 .NET 性能系列的第三篇(前两篇是 Dump 诊断、高并发编程)。前两篇讲"出问题怎么查"和"怎么扛住流量",这篇讲怎么跑得更快——如何用工具定位性能瓶颈,以及优化手段。
性能优化最容易犯的错是"凭感觉优化"。本文的核心是:先测量,再优化。掌握工具链比记住一堆技巧重要。
版本说明:基于 .NET 8 LTS。NativeAOT、InterpolatedStringHandler 等特性在 .NET 8 上均适用。
一、性能优化的黄金法则
1.1 测量优先
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| 性能优化第一定律:
"过早优化是万恶之源" —— Donald Knuth
正确的流程:
1. 建立基线(当前性能是多少)
2. 用工具定位瓶颈(慢在哪、为什么慢)
3. 针对瓶颈优化
4. 测量优化效果(验证真的变快了)
5. 回归测试(没破坏功能)
错误的做法:
✗ 凭直觉优化("我觉得这里慢")
✗ 优化不重要的代码(占总时间 1% 的地方优化到极致没用)
✗ 没有基线就改(不知道改完是快了还是慢了)
✗ 牺牲可读性换微优化(得不偿失)
|
1.2 帕累托法则
1
2
3
4
5
6
7
8
9
10
11
| 80% 的时间花在 20% 的代码上
优化策略:
找到那 20%(热点路径)→ 集中优化
其余 80% 的代码 → 保持简洁可读
热点路径通常是:
- 循环体
- 高频调用的方法
- I/O 操作(数据库、HTTP、磁盘)
- 序列化/反序列化
|
1.3 优化的层级
1
2
3
4
5
6
7
8
9
10
| 优化收益从大到小:
1. 架构优化 — 加缓存、异步化、读写分离、消息队列(收益 10x~100x)
2. 算法优化 — O(n²) → O(n log n)(收益 10x)
3. I/O 优化 — 减少 DB 往返、批量、连接复用(收益 5x)
4. 实现优化 - LINQ 改循环、避免分配(收益 2x)
5. 微优化 — struct、Span、内联(收益 1.2x)
从上层往下做,微优化放最后。
架构不对,微优化再多是杯水车薪。
|
1.4 性能问题的分类
动手前先判断"是哪一类问题",这决定了用什么工具、查什么方向。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| 按资源维度分类:
CPU 密集型
特征:CPU 持续 > 80%,吞吐上不去
场景:复杂计算、正则滥用、低效算法
看指标:cpu-usage 高
工具:dotnet-trace(火焰图找热点函数)
内存/GC 密集型
特征:% Time in GC 高、内存持续增长
场景:过度分配、内存泄漏、LOH 碎片
看指标:time-in-gc、gc-heap-size、gen-2-gc-count
工具:dotnet-gcdump / dotnet-dump
I/O 密集型
特征:CPU 不高但响应慢
场景:数据库慢查询、外部 API、磁盘读写
看指标:cpu-usage 低 + 响应慢
工具:APM 链路追踪、数据库慢日志
锁竞争
特征:多核机器但只有一个核跑满,其他在等锁
场景:全局锁、lock 持有时间过长
看指标:contention rate 高
工具:dotnet-stack(看线程在等什么)
|
1
2
3
4
5
6
7
8
9
| 按症状快速排查:
症状 可能原因 排查方向
─────────────────────────────────────────────────────────────
启动慢 依赖加载、JIT、初始化 启动日志、模块加载
请求响应慢 DB 查询、外部 API、计算 APM 追踪、慢日志
内存持续增长 泄漏、缓存无上限 gcdump 分析、GC 日志
CPU 飙升 死循环、异常、高频计算 火焰图、线程栈
间歇性卡顿 GC、定时任务、线程池耗尽 GC 日志、线程池监控
|
先分清类别,再选工具——避免拿着锤子到处找钉子。
二、性能分析工具链
.NET 有世界级的诊断工具链(dotnet-* 全家桶),覆盖从监控到采样到内存分析。
1
2
3
4
5
6
7
| 工具 用途 频率
──────────────────────────────────────────────────────
dotnet-counters 实时指标监控(CPU/GC/线程池) 实时
dotnet-trace CPU 采样、事件追踪、火焰图 录制
dotnet-stack 实时线程栈快照 实时
dotnet-dump 内存/线程/锁分析(离线) 抓取
dotnet-gcdump 托管堆分析(轻量) 抓取
|
2.1 dotnet-counters — 实时指标
第一步永远是看指标,判断瓶颈类型(CPU?内存?GC?线程池?)。
1
2
3
4
5
6
7
8
9
| # 安装
dotnet tool install -g dotnet-counters
# 实时监控(默认 System.Runtime)
dotnet-counters monitor -p 12345
# 指定监控的计数器
dotnet-counters monitor -p 12345 \
System.Runtime[cpu-usage,gc-heap-size,gen-0-gc-count,gen-1-gc-count,gen-2-gc-count,time-in-gc,threadpool-queue-length]
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| 关键指标解读:
指标 含义 正常 异常/告警
───────────────────────────────────────────────────────────────────────
cpu-usage CPU 使用率 < 60% > 80% 持续
time-in-gc GC 占 CPU 比例 < 10% > 20%
alloc-rate 分配速率 稳定 持续高位
gen-0-gc-count Gen 0 GC 次数 频繁(正常) —
gen-1-gc-count Gen 1 GC 次数 偶尔 频繁
gen-2-gc-count Gen 2 GC 次数 很少(几分钟一次) 频繁
gc-heap-size GC 堆大小 稳定 持续增长(泄漏)
threadpool-queue-length 线程池队列 < 10 > 100
exception-count 异常总数 极少 持续增长
working-set 内存占用 稳定 持续增长
诊断思路:
cpu-usage 高 + time-in-gc 低 → 真正的 CPU 计算(算法/循环)
cpu-usage 不高但卡 → I/O 等待或锁竞争
time-in-gc 高(> 20%) → 分配过多,要减少分配
threadpool-queue-length 高 → 线程阻塞(同步 I/O / 死锁)
|
2.2 dotnet-trace — CPU 采样与火焰图
dotnet-counters 告诉你"有问题",dotnet-trace 告诉你"问题在哪行代码"。
1
2
3
4
5
6
7
8
9
| # 安装
dotnet tool install -g dotnet-trace
# CPU 采样,录 30 秒,输出 speedscope 格式
dotnet-trace collect -p 12345 --profile cpu-sampling --format Speedscope -d 30
# 输出:xxx.speedscope.json
# 也可以用 nettrace 格式(PerfView 打开)
dotnet-trace collect -p 12345 -d 30
|
录完生成的 .speedscope.json 文件,上传到 https://speedscope.app 即可看火焰图。
高级:直接指定 ETW Provider
--profile cpu-sampling 是预设组合。需要更细的事件(比如只看 GC 分配),可以直接指定 ETW Provider 关键字:
1
2
3
4
5
6
7
8
9
| # 只收集 GC 事件(找 GC 热点)
dotnet-trace collect -p 12345 \
--providers Microsoft-DotNETCore-SampleProfiler,Microsoft-Windows-DotNETRuntime:0x1:4 \
-d 30
# 专门收集堆分配(0x10000,找分配热点最有用)
dotnet-trace collect -p 12345 \
--providers Microsoft-Windows-DotNETRuntime:0x10000:4 \
-d 30
|
1
2
3
4
5
6
7
8
| DotNETRuntime 常用关键字(掩码):
0x1 — GC(垃圾回收事件)
0x4 — JIT(即时编译)
0x10000 — GC Heap Allocation(堆分配,找分配热点最有用)
0x20000 — GC Heap Survive and Movement(对象存活/移动)
格式:Provider:关键字:级别(4 = Informational)
排查分配问题,0x10000 最有用
|
2.3 dotnet-stack — 实时线程栈
不抓 dump,直接看所有线程当前在执行什么。适合排查"瞬时卡顿"。
1
2
3
4
5
6
7
8
| # 安装
dotnet tool install -g dotnet-stack
# 打印所有线程栈(一次性快照)
dotnet-stack report -p 12345
# 持续轮询(每秒一次,看趋势)
dotnet-stack report -p 12345 --duration 00:00:30
|
2.4 内存分析(dotnet-dump / dotnet-gcdump)
内存泄漏和分配分析详见 《.NET Dump 诊断》 那篇,这里只列要点:
1
2
3
4
5
6
7
8
| # 轻量堆 dump,看分配和引用
dotnet-gcdump collect -p 12345
# 完整 dump,深入分析
dotnet-dump collect -p 12345
dotnet-dump analyze xxx.dmp
> dumpheap -stat # 按类型统计
> gcroot <地址> # 引用链
|
三、火焰图:定位 CPU 热点
火焰图是性能分析最有力的工具,但很多人不会读。专门讲一下。
3.1 什么是火焰图
1
2
3
4
5
6
7
8
| 火焰图把采样数据可视化:
- 横轴:函数调用栈(展开成"火焰")
- 纵轴:调用深度(底层是调用者,顶层是被调用者)
- 宽度:该函数占用 CPU 的比例(越宽 = 越慢 = 优化重点)
读法核心:
看最宽的"墙"——那是最耗时的调用栈
从下往上读,找到最宽的那一层,就是瓶颈函数
|
3.2 火焰图示例
1
2
3
4
5
6
7
8
9
10
11
12
13
| ┌──────────────────────────────────────────────────────────┐
│ [Idle] │ ← 空闲
├──────────────────────────────────────────────────────────┤
│ Run() │
│ ├────────────────────────────┬─────────────────┤ │
│ │ ProcessRequest() │ LogRequest() │ │
│ │ ├───────────────┬────────┐ │ ├──────────┐ │ │
│ │ │ ParseJson() │ Query()│ │ │ Write() │ │ │
│ │ │ ███████████ │ ██████│ │ │ ██ │ │ │
│ │ │ (很宽=慢) │ │ │ │ │ │ │
└────────┴───────────────┴────────┘─┴─┴──────────┘────┘
↑ 宽度=CPU占比
读图:ParseJson() 那块最宽 → 它是瓶颈 → 优化它
|
3.3 两种视角
1
2
3
4
5
6
7
8
| speedscope 提供三种视图:
1. Time Order(时间顺序)— 按采样时间排列,看某个时刻在干嘛
2. Left Heavy(左重) — 按调用栈聚合,最常用,找最宽的块
3. Sandwich(三明治) — 按函数汇总,看每个函数的总耗时和调用次数
找瓶颈:用 Left Heavy,看最宽的栈
对比函数:用 Sandwich,按 Total Time 排序
|
3.4 常见火焰图形态
1
2
3
4
5
6
7
8
9
10
11
| 宽且平的"平台" → 某个函数本身耗时(CPU 密集计算)
→ 优化算法或实现
窄而深的"塔" → 调用链很长,但每层不耗时
→ 通常不是瓶颈,看塔顶
突然变宽的"瓶颈层" → 某一层突然占满,下面层层都宽
→ 那一层就是热点
大片 [Idle] → CPU 空闲,瓶颈在 I/O 或锁(不在 CPU)
→ 改用 dotnet-stack 看线程在等什么
|
四、BenchmarkDotNet — 微基准测试
想优化具体方法、对比两种实现,不要用 Stopwatch 手写——会测不准。用 BenchmarkDotNet。
4.1 为什么不能用 Stopwatch
1
2
3
4
5
6
7
8
9
10
11
12
13
| // 错误:手写基准
var sw = Stopwatch.StartNew();
for (int i = 0; i < 1000; i++)
MethodA();
sw.Stop();
Console.WriteLine(sw.ElapsedMilliseconds);
// 问题:
// ✗ 没预热(JIT 第一次执行慢)
// ✗ 没考虑 GC 时机(GC 恰好发生在 MethodA 里)
// ✗ 编译器可能优化掉"没用的"循环
// ✗ 没考虑 CPU 频率波动、其他进程干扰
// ✗ 样本太少,没有统计意义
|
4.2 基本用法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| // 安装
// dotnet add package BenchmarkDotNet
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser] // 报告内存分配和 GC 次数
public class StringBenchmarks
{
[Benchmark(Baseline = true)]
public string StringConcat()
{
var s = "";
for (int i = 0; i < 100; i++)
s += i.ToString();
return s;
}
[Benchmark]
public string StringBuilder()
{
var sb = new StringBuilder();
for (int i = 100; i > 0; i--)
sb.Append(i);
return sb.ToString();
}
}
// 入口
var summary = BenchmarkRunner.Run<StringBenchmarks>();
|
4.3 读懂报告
1
2
3
4
5
6
7
8
9
10
11
12
| | Method | Mean | Error | StdDev | Ratio | Allocated |
|--------------- |---------:|---------:|---------:|------:|----------:|
| StringConcat | 15.32 us | 0.312 us | 0.456 us | 1.00 | 6000 B |
| StringBuilder | 2.14 us | 0.043 us | 0.061 us | 0.14 | 320 B |
解读:
Mean 平均耗时
StdDev 标准差(越小越稳定)
Ratio 相对基线的倍数(0.14 = 比 StringConcat 快 7 倍)
Allocated 分配的内存(6000B vs 320B,差异巨大)
结论:StringBuilder 比 += 快 7 倍,内存少 18 倍
|
4.4 常用特性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| [SimpleJob(RuntimeMoniker.Net80)] // 指定运行时
[MemoryDiagnoser] // 内存/GC 诊断
[ThreadingDiagnoser] // 线程池诊断
public class MyBenchmarks
{
[Params(100, 1000, 10000)] // 多种参数
public int N { get; set; }
[ParamsAllValues] // 所有值
public StringComparison SC { get; set; }
[Benchmark]
public void Method() { /* 用 N */ }
[Benchmark]
[Arguments(1, "test")] // 直接传参
public void WithArgs(int a, string b) { }
}
|
1
2
3
4
5
6
7
8
| BenchmarkDotNet 的保证:
✓ 预热(JIT 编译、CPU 升频)
✓ 多轮采样,统计置信区间
✓ 隔离 GC 干扰
✓ 防止编译器"优化掉"测试代码
✓ 报告 Mean / Median / StdDev / Allocated / Gen0/1/2
这是工业级的基准测试,结果可信
|
五、GC 调优
GC(垃圾回收)是 .NET 性能的核心。理解 GC 才能理解"为什么要减少分配"。
5.1 GC 基础
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| .NET 的 GC 是分代的:
Gen 0 — 短生命周期对象,回收最频繁、最快
Gen 1 — 缓冲区
Gen 2 — 长生命周期对象,回收最慢(全堆扫描)
LOH — 大对象堆(≥ 85000 字节),直接进 Gen 2
回收流程:
Gen 0 满 → 回收 Gen 0(快)
存活的对象晋升 Gen 1
Gen 1 满 → 回收 Gen 1(连带 Gen 0)
存活的晋升 Gen 2
Gen 2 满 → 完整 GC(慢,可能 STW 几十毫秒)
性能核心:减少 Gen 2 回收
Gen 2 GC 贵,会停顿所有线程(STW)
办法:减少分配、复用对象、避免大对象频繁分配
|
5.2 Workstation GC vs Server GC
1
2
3
4
5
6
7
| <!-- runtimeconfig.json / .csproj -->
{
"configProperties": {
"System.Runtime.GC.Server": true, // Server GC
"System.Runtime.GC.Concurrent": true // 后台 GC
}
}
|
1
2
3
4
5
6
7
8
9
10
| Workstation GC Server GC
──────────────────────────────────────────────────────────
堆数量 1 个 每核 1 个(并行回收)
线程 少 多(每核 GC 线程)
吞吐量 低 高
内存占用 小 大(多堆)
适用 客户端、单核 服务器、多核、高并发
ASP.NET Core 默认 Server GC(多核服务器最优)
桌面应用用 Workstation GC
|
1
2
3
| // 代码里判断当前 GC 模式
Console.WriteLine(GCSettings.IsServerGC); // 是否 Server GC
Console.WriteLine(GCSettings.LatencyMode); // 延迟模式
|
5.3 GC 延迟模式
1
2
3
4
5
6
7
8
9
10
11
12
| // 临时降低 GC 影响(关键路径不想被打断)
var oldMode = GCSettings.LatencyMode;
try
{
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
// 这段代码期间,GC 尽量不做完整回收
DoLatencySensitiveWork();
}
finally
{
GCSettings.LatencyMode = oldMode; // 恢复
}
|
1
2
3
4
5
| GCLatencyMode:
Interactive 标准(平衡吞吐和延迟)
LowLatency 短时低延迟(Gen 2 回收被抑制,仅 Gen 0/1)
SustainedLowLatency 持续低延迟(适合实时场景)
Batch 最高吞吐(不顾延迟,适合后台任务)
|
5.4 减少分配(GC 调优的根本)
GC 压力的根源是分配。分配越少,GC 越少。
1
2
3
4
5
6
7
8
| 减少分配的手段(实战角度,详见高并发文章的 Span/Pool 章节):
✓ Span<T> / stackalloc — 零拷贝、栈分配
✓ ArrayPool<T>.Shared — 复用数组
✓ ObjectPool<T> — 复用对象
✓ 预分配集合容量 — new List<T>(128) 避免 resize
✓ struct 替代 class — 栈分配,无 GC(适用时)
✓ 避免 LINQ 在热路径 — 每次都分配迭代器
✓ string 插值替代拼接 — C# 10+ 的 InterpolatedStringHandler
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // GC 通知:在 Gen 2 回收前收到通知(适合做平滑处理)
GC.RegisterForFullGCNotification(10, 10);
// 在后台线程轮询
Task.Run(() =>
{
while (true)
{
GCNotificationStatus s = GC.WaitForFullGCApproach();
if (s == GCNotificationStatus.Succeeded)
{
// Gen 2 即将发生,可做些准备(如拒绝新请求)
}
GC.WaitForFullGCComplete();
}
});
|
六、JIT 与 AOT
.NET 默认是 JIT(即时编译),但也有 AOT(提前编译) 选项。
6.1 JIT 工作原理
1
2
3
4
5
6
7
8
9
10
11
12
| 源码 → IL(中间语言) → JIT → 机器码
JIT 的特点:
✓ 方法首次调用时才编译(按需)
✓ 基于实际运行情况优化(分层编译、PGO)
✓ 跨平台(IL 与 CPU 无关)
✗ 首次调用有编译开销(冷启动慢)
✗ 运行时占用内存(JIT + 优化代码)
.NET 8 的优化:
分层编译(Tiered Compilation)— 先快速编译,热点再重新优化
动态 PGO(Dynamic PGO) — 根据运行 profile 优化热点
|
6.2 ReadyToRun(R2R)— 预编译 IL
1
2
| # 发布时预编译为机器码(但仍是托管,可 JIT 兜底)
dotnet publish -c Release -r win-x64 -p:PublishReadyToRun=true
|
1
2
3
4
5
6
7
| R2R 的特点:
✓ 启动更快(部分方法已编译)
✓ 仍是托管代码(可被 JIT 进一步优化)
✗ 文件更大(包含预编译代码)
✗ 绑定平台(win-x64 / linux-x64)
适合:启动速度敏感、可接受更大体积的场景
|
6.3 NativeAOT — 完全提前编译
1
2
3
4
5
6
| # .NET 8 正式支持
dotnet new console
# 在 csproj 加 <PublishAot>true</PublishAot>
dotnet publish -c Release -r win-x64
# 输出:单个原生可执行文件,无需 .NET 运行时
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| NativeAOT 的特点:
✓ 启动极快(无 JIT)
✓ 内存占用小(无运行时)
✓ 单文件部署,无依赖
✓ 部署到容器/边缘设备
✗ 不支持反射(或需显式 trim)
✗ 不支持动态加载程序集
✗ 编译时绑定平台
✗ 部分库不兼容(需要 AOT 友好)
适合:
CLI 工具、云函数(冷启动敏感)、微服务、边缘计算
不适合:
重度反射、动态代码生成的应用
|
1
2
3
4
| JIT vs R2R vs NativeAOT 选择:
标准部署(服务器、长期运行) → JIT(运行时优化最强)
启动敏感、要兜底 → R2R
冷启动极致、单文件、无依赖 → NativeAOT
|
七、常见性能反模式
实际项目中最常见的性能坑,附优化前后对比。
7.1 字符串拼接
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // ❌ 慢:循环里 += (每次创建新字符串)
string result = "";
foreach (var item in items)
result += item.ToString(); // O(n²),大量中间字符串
// ✅ 快:StringBuilder
var sb = new StringBuilder();
foreach (var item in items)
sb.Append(item);
var result = sb.ToString(); // O(n)
// ✅ 更快:string.Join(C# 内部优化)
var result = string.Join(",", items);
// ✅ 现代插值(C# 10+,零分配优化)
// InterpolatedStringHandler 直接写入目标,不分配中间 string
$"Total: {count:N0} items"; // 高效
|
7.2 LINQ 误用
1
2
3
4
5
6
7
8
9
10
11
12
13
| // ❌ 热路径用 LINQ(每次分配迭代器、委托)
foreach (var x in list.Where(x => x.Active).Select(x => x.Value))
Process(x);
// ✅ 热路径用显式循环
foreach (var x in list)
{
if (x.Active)
Process(x.Value);
}
// LINQ 适合可读性优先的非热路径;
// 性能敏感的热路径,显式循环更快(少分配)
|
7.3 装箱(Boxing)
1
2
3
4
5
6
7
8
9
10
11
12
| // ❌ 装箱:值类型转 object,堆分配
ArrayList list = new ArrayList(); // 老式集合,存 object
list.Add(42); // int → object,装箱!分配
// ✅ 泛型集合,无装箱
List<int> list = new List<int>();
list.Add(42); // 无装箱
// 隐蔽的装箱:
Console.WriteLine("Count: " + count); // string + int → 装箱
// C# 6+ 插值优化后:
Console.WriteLine($"Count: {count}"); // 无装箱(InterpolatedStringHandler)
|
7.4 async 误用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // ❌ async void(异常无法捕获,会崩溃进程)
async void DoWork() { await Task.Delay(100); throw new Exception(); }
// ✅ async Task
async Task DoWorkAsync() { await Task.Delay(100); }
// ❌ 异步里同步阻塞(死锁风险 + 线程池耗尽)
async Task BadAsync()
{
var result = SomeSyncMethod().Result; // .Result 阻塞!
}
// ✅ 全程异步
async Task GoodAsync()
{
var result = await SomeMethodAsync();
}
// ❌ Task.Run 包异步方法(多此一举,浪费线程)
var x = await Task.Run(async () => await httpClient.GetStringAsync(url));
// ✅ 直接 await(HttpClient 本身是异步 I/O)
var x = await httpClient.GetStringAsync(url);
|
7.5 集合未预分配容量
1
2
3
4
5
6
7
8
9
10
11
12
13
| // ❌ 不指定容量:List 多次扩容(每次复制数组)
var list = new List<int>();
for (int i = 0; i < 10000; i++)
list.Add(i); // 多次 resize + 复制
// ✅ 预分配容量:一次到位
var list = new List<int>(10000);
for (int i = 0; i < 10000; i++)
list.Add(i); // 无 resize
// 同理:Dictionary、HashSet、StringBuilder 都支持容量
var dict = new Dictionary<string, int>(capacity: 1000);
var sb = new StringBuilder(capacity: 256);
|
7.6 闭包捕获
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // ❌ 闭包捕获循环变量(分配委托 + 闭包对象)
foreach (var item in items)
{
tasks.Add(Task.Run(() => Process(item))); // 每次分配闭包
}
// ✅ 显式传参(无闭包分配)
foreach (var item in items)
{
var local = item;
tasks.Add(Task.Run(() => Process(local)));
}
// 热路径避免频繁创建 lambda / 闭包
|
八、减少分配实战(复习)
减少分配 = 减少 GC = 提升性能。这部分在高并发文章详细讲过,这里从性能角度快速复习。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| // 1. ArrayPool — 复用数组(热路径必备)
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
try { Process(buffer); }
finally { ArrayPool<byte>.Shared.Return(buffer); }
// 2. stackalloc — 栈分配(小缓冲,零 GC)
Span<byte> stackBuf = stackalloc byte[128];
// 3. string.Create — 高效构造字符串
string result = string.Create(length, state, (span, s) =>
{
// 直接在目标内存写入,无中间分配
for (int i = 0; i < span.Length; i++)
span[i] = (char)('0' + (s.Value % 10));
});
// 4. struct 而非 class(适用时,避免堆分配)
// 小型、值语义的数据用 readonly struct
public readonly struct Point { public int X; public int Y; }
// 5. 避免防御性拷贝:用 readonly struct / in 参数
static void Process(in BigStruct data) { } // in = 按引用只读传入
|
九、性能优化流程总结
把前面所有内容串成一个可执行的流程:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| 1. 建立基线
dotnet-counters 看整体指标
记录当前 QPS、P99 延迟、CPU、内存
2. 定位瓶颈类型
CPU 高?→ CPU 密集(算法/分配)
CPU 低但卡?→ I/O 等待 / 锁竞争
time-in-gc 高?→ 分配过多
threadpool-queue 长?→ 线程阻塞
3. 定位瓶颈位置
CPU 密集 → dotnet-trace 录火焰图 → 找最宽的栈
瞬时卡顿 → dotnet-stack 看线程栈
内存问题 → dotnet-gcdump / dotnet-dump
4. 针对优化
算法层 → 换数据结构 / 算法
I/O 层 → 缓存、批量、异步、连接池
实现层 → 减少分配、Span、避免 LINQ 热路径
用 BenchmarkDotNet 验证优化效果
5. 验证收益
重新测量,对比基线
确认真的变快了(不是测量误差)
6. 回归测试
确保功能没坏
加上 benchmark 防止性能退化
|
优化决策树
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| 慢在哪?
├─ CPU 高
│ ├─ 火焰图找到热点函数
│ ├─ 算法可优化?→ 换算法(收益最大)
│ ├─ 分配多?→ 减少分配(Span/Pool/容量)
│ └─ 计算密集?→ 并行化(Parallel/PLINQ)
├─ I/O 等待
│ ├─ 数据库?→ 缓存、批量、索引、连接池
│ ├─ HTTP?→ 并发(Task.WhenAll)、连接池、重试
│ └─ 全异步化(async/await 全链路)
├─ 内存/GC
│ ├─ time-in-gc 高?→ 减少分配
│ ├─ LOH 频繁?→ 复用大对象(ArrayPool)
│ └─ Server GC 开启?
└─ 锁竞争
└─ 无锁化(Channel/并发集合/Interlocked)
|
十、实战案例
把前面的工具和流程串起来,看两个真实场景。
10.1 场景一:Web API 响应慢
现象:用户反馈接口响应 5 秒+,但 APM 显示数据库查询只要 100ms。
第一步:看指标判断类型
1
2
3
| dotnet-counters monitor -p 12345 System.Runtime
# cpu-usage 飙到 95%,time-in-gc 正常
# → 判定:CPU 密集型(不是 I/O,不是 GC)
|
第二步:录火焰图找热点
1
2
| dotnet-trace collect -p 12345 --profile cpu-sampling --format Speedscope -d 30
# 在 speedscope 打开,发现 80% CPU 花在某个 LINQ 查询
|
第三步:定位代码
1
2
3
4
5
6
| // 火焰图指向这段:循环里调 .Any(),触发 N+1 查询
foreach (var order in orders)
{
var hasDetails = _context.OrderDetails
.Any(x => x.OrderId == order.Id); // 每个 order 一次 DB 往返
}
|
第四步:修复(批量查询替代循环查询)
1
2
3
4
5
6
7
8
| var orderIds = orders.Select(x => x.Id).ToList();
var hasDetailsMap = _context.OrderDetails
.Where(x => orderIds.Contains(x.OrderId))
.GroupBy(x => x.OrderId)
.ToDictionary(g => g.Key, g => g.Any()); // 一次查询全部
foreach (var order in orders)
_ = hasDetailsMap.ContainsKey(order.Id);
|
第五步:Benchmark 验证
1
2
3
| 修复前:4500 ms, 1200 MB allocated
修复后: 120 ms, 25 MB allocated
提速 37 倍,分配减少 48 倍
|
10.2 场景二:内存持续增长(泄漏)
现象:进程内存每小时涨 200MB,最终 OOM 重启。
第一步:确认是泄漏
1
2
3
| dotnet-counters monitor -p 12345 System.Runtime
# gc-heap-size 持续增长,gen-2-gc-count 越来越频繁
# → 判定:托管内存泄漏
|
第二步:抓 gcdump 看谁占内存
1
2
3
| dotnet-gcdump collect -p 12345
# 用 PerfView / dotMemory 打开
# 发现 50 万个 DataItem 对象,疑似泄漏
|
第三步:用 dump 找引用链
1
2
3
4
5
6
| dotnet-dump collect -p 12345
dotnet-dump analyze xxx.dmp
> dumpheap -type DataItem
> gcroot <DataItem 地址>
# 输出引用链:
# EventHandler[] → DataService.DataReceived → DataItem
|
第四步:定位根因
1
2
3
| // 事件订阅了但从没取消,DataService 是长生命周期单例
service.DataReceived += OnDataReceived;
// DataConsumer 被 DataReceived 持有,永不释放
|
第五步:修复
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| public class DataConsumer : IDisposable
{
private readonly DataService _service;
private readonly EventHandler<DataEventArgs> _subscription;
public DataConsumer(DataService service)
{
_service = service;
_subscription = OnDataReceived;
_service.DataReceived += _subscription;
}
public void Dispose()
{
_service.DataReceived -= _subscription; // 释放时取消订阅
}
}
|
总结:内存泄漏三件套——dumpheap -stat 找大户、gcroot 找引用链、改代码断开引用。详见 《.NET Dump 诊断》。
十一、小结
本文系统讲解了 .NET 性能优化与 profiling:
- 方法论:测量优先、帕累托、优化层级(架构 > 算法 > I/O > 实现 > 微优化)
- 工具链:dotnet-counters(指标)、dotnet-trace(火焰图)、dotnet-stack(线程栈)、dotnet-dump/gcdump(内存)
- 火焰图:怎么读,看最宽的栈找瓶颈
- BenchmarkDotNet:工业级微基准,不要用 Stopwatch 手写
- GC 调优:分代、Server GC、延迟模式、减少分配是根本
- JIT / AOT:R2R、NativeAOT 的适用场景
- 反模式:字符串拼接、LINQ 热路径、装箱、async 误用、容量、闭包
- 优化流程:基线 → 定位 → 优化 → 验证 → 回归
- 实战案例:API 慢(N+1 查询)、内存泄漏(事件订阅)端到端排查
性能优化的核心心法:先测量,再优化;先架构,再微调;先热点,再全局。
至此 .NET 性能系列三篇(Dump 诊断、高并发、性能优化)完结。