20. 生产环境诊断实战:当程序卡死时,你该怎么办?
本章 GitHub 仓库:csharp-concurrency-cookbook ⭐
欢迎 Star 和 Fork!所有诊断示例代码都在
ProductionDiagnostics项目中。
🎯 本章导读
这一章,我们就来聊聊这个每个开发者都可能遇到的噩梦场景。与前面章节的"如何写好代码"不同,这次我们要学的是"当代码出问题时,如何快速定位和解决"。
本章将回答以下核心问题:
- 死锁排查:程序卡死、日志停止输出,如何定位是哪两个线程互相等待?
- 线程池饥饿:吞吐量突然下降,如何判断是不是线程池线程被耗尽了?
- 内存泄漏:内存持续增长不释放,如何找到是哪个对象没有被 GC 回收?
- CPU 占用过高:CPU 100%,如何快速定位是哪个方法在疯狂消耗 CPU?
这些都是生产环境的"疑难杂症",掌握诊断方法,你就能从一个普通开发者进阶为能独当一面的高级开发者。
⚠️ 重要提示:
- 本章的示例代码都是故意写错的,用于模拟生产环境问题
- 建议在测试环境或本地运行这些示例,不要在生产环境直接运行
- 诊断工具的使用需要一定的练习,建议跟着示例一步步操作
0️⃣ 为什么需要诊断技能?写好代码不就行了吗?
在开始之前,我想先聊聊一个很多初级开发者容易有的误区:
"我的代码没问题,测试也都通过了,应该不会有问题吧?"
现实很残酷:
-
测试环境和生产环境不一样:
- 测试环境可能只有 10 个并发用户,生产环境是 10000 个
- 测试环境数据量小,生产环境数据量大
- 测试环境网络延迟低,生产环境网络不稳定
-
有些问题只有在特定条件下才会触发:
- 死锁需要特定的执行顺序
- 内存泄漏需要长时间运行才能发现
- 性能问题需要高负载才能暴露
-
代码是会变的:
- 你改了一个看似无害的逻辑
- 同事改了一个依赖的库
- 框架升级引入了新的 bug
所以,学会诊断和排查问题,和学会写代码一样重要。
1️⃣ 死锁排查:程序卡死了怎么办?
1.1 什么是死锁?
先来回顾一下第 11 章讲过的死锁概念:
死锁:两个或多个线程相互等待对方释放锁,导致所有线程都无法继续执行。
经典场景:
线程 A:获取了 Lock1,等待 Lock2
线程 B:获取了 Lock2,等待 Lock1
↓
永远等下去...
1.2 死锁的现象
生产环境中,你怎么知道是死锁呢?看这些信号:
- ✅ 程序无响应,但没有崩溃
- ✅ CPU 占用很低(因为线程都在等待,不消耗 CPU)
- ✅ 日志停止输出(写日志的线程也被锁住了)
- ✅ 用户请求全部超时
1.3 死锁演示代码
让我们先制造一个死锁,然后学习如何诊断它:
// 来自 DeadlockDemo.cs
private static readonly object _lock1 = new();
private static readonly object _lock2 = new();// 线程 1
var thread1 = new Thread(() =>
{lock (_lock1){Console.WriteLine($"[Thread {Environment.CurrentManagedThreadId}] 获取了 Lock1");Thread.Sleep(1000); // 确保线程2也能获取到lock2Console.WriteLine($"[Thread {Environment.CurrentManagedThreadId}] 尝试获取 Lock2...");lock (_lock2) // ❌ 等待线程2释放lock2{Console.WriteLine($"[Thread {Environment.CurrentManagedThreadId}] 获取了 Lock2");}}
});// 线程 2
var thread2 = new Thread(() =>
{lock (_lock2){Console.WriteLine($"[Thread {Environment.CurrentManagedThreadId}] 获取了 Lock2");Thread.Sleep(1000); // 确保线程1也能获取到lock1Console.WriteLine($"[Thread {Environment.CurrentManagedThreadId}] 尝试获取 Lock1...");lock (_lock1) // ❌ 等待线程1释放lock1{Console.WriteLine($"[Thread {Environment.CurrentManagedThreadId}] 获取了 Lock1");}}
});thread1.Start();
thread2.Start();
运行后,程序会卡住不动。这时候怎么办?
1.4 使用 dotnet-dump 诊断死锁
工具安装:
dotnet tool install --global dotnet-dump注:
安装的时候,因为网络等原因,可能会很慢,且这时候界面没有任何的输出和提示,请耐心等待。直到命令行界面出现如下文字,则表示安装成功:
可使用以下命令调用工具: dotnet-dump
已成功安装工具“dotnet-dump”(版本“9.0.661903”)。
诊断步骤:

步骤 1:找到进程 ID
# Windows
tasklist | findstr ProductionDiagnostics# Linux/macOS
ps aux | grep ProductionDiagnostics
假设进程 ID 是 12345。
步骤 2:抓取内存转储(dump)
dotnet-dump collect -p 12345
这会生成一个 .dmp 文件,比如 dump_20260719_213641.dmp。
步骤 3:分析 dump 文件
dotnet-dump analyze dump_20260719_213641.dmp
进入交互式分析界面。
步骤 4:查看所有线程
> clrthreads
输出示例:
ThreadCount: 4
UnstartedThread: 0
BackgroundThread: 1
PendingThread: 0
DeadThread: 0
Hosted Runtime: noLockDBG ID OSID ThreadOBJ State GC Mode GC Alloc Context Domain Count Apt Exception3 1 3adc 000001ED9BDE3910 21220 Preemptive 000001EDA0818CD8:000001EDA081AC10 000001ED9BDC3980 -00001 MTA (Finalizer)0 2 a07c 000001ED9BD87340 202a020 Preemptive 000001EDA0813C38:000001EDA0814BB0 000001ED9BDC3980 -00001 MTA4 4 a4b0 000001ED9BD86350 202b020 Preemptive 000001EDA08170E0:000001EDA0818BF0 000001ED9BDC3980 -00001 MTA5 5 aa90 000001ED9BD869B0 202b020 Preemptive 000001EDA08156E0:000001EDA0816BD0 000001ED9BDC3980 -00001 MTA
注意 State 列,202b020 表示线程在等待锁。
步骤 5:查看同步块(找出哪些线程在等待锁)
> syncblk
输出示例:
Index SyncBlock MonitorHeld Recursion Owning Thread Info SyncBlock Owner3 000001ED9BE4C298 3 1 000001ED9BD869B0 aa90 5 000001eda0814be0 System.Object4 000001ED9BE4C2F0 3 1 000001ED9BD86350 a4b0 4 000001eda0814bc8 System.Object
-----------------------------
Total 4
CCW 0
RCW 0
ComClassFactory 0
Free 0
解读:
-
上面的
Info的值就是线程ID -
Index 3:线程aa90持有一个锁,等待另一个锁 -
Index 4:线程a4b0持有另一个锁,等待第一个锁 -
这就是死锁!
步骤 6:查看并行堆栈(可视化线程状态)
> parallelstacks
输出示例:
________________________________________________~~~~ a07c1 System.Threading.Thread.Join(Int32)1 System.Threading.Thread.Join()1 ProductionDiagnostics.DeadlockDemo.Run()1 ProductionDiagnostics.Program+<Main>d__0.MoveNext()1 System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start(<Main>d__0 ByRef)1 System.Runtime.CompilerServices.AsyncTaskMethodBuilder.Start(<Main>d__0 ByRef)1 ProductionDiagnostics.Program.Main(String[])1 ProductionDiagnostics.Program.<Main>(String[])________________________________________________~~~~ a4b01 System.Threading.Monitor.Enter_Slowpath(Object)1 System.Threading.Monitor.Enter(Object, Boolean ByRef)1 ProductionDiagnostics.DeadlockDemo+<>c.<Run>b__2_0()~~~~ aa901 System.Threading.Monitor.Enter_Slowpath(Object)1 System.Threading.Monitor.Enter(Object, Boolean ByRef)1 ProductionDiagnostics.DeadlockDemo+<>c.<Run>b__2_1()2 System.Threading.Thread.StartCallback()
这会显示哪些线程在等待锁。

1.5 如何预防死锁?
看到这里,你可能会说:"好,我知道怎么诊断了,但怎么避免死锁?"
预防死锁的四大原则:
- 避免嵌套锁(最简单有效):
// ❌ 错误:嵌套锁
lock (_lock1)
{lock (_lock2){// 危险!}
}// ✅ 正确:只用一个锁
lock (_lock)
{// 安全
}
- 统一加锁顺序:
// ✅ 所有地方都先获取 Lock1,再获取 Lock2
lock (_lock1)
{lock (_lock2){// 安全}
}
- 使用超时机制:
// ✅ 使用 Monitor.TryEnter,避免永久等待
if (Monitor.TryEnter(_lock1, TimeSpan.FromSeconds(5)))
{try{if (Monitor.TryEnter(_lock2, TimeSpan.FromSeconds(5))){try{// 安全执行}finally{Monitor.Exit(_lock2);}}else{Console.WriteLine("获取 Lock2 超时");}}finally{Monitor.Exit(_lock1);}
}
else
{Console.WriteLine("获取 Lock1 超时");
}
- 使用更高级的同步原语:
SemaphoreSlim:信号量,避免多个锁ReaderWriterLockSlim:读写锁,读读不互斥Channel<T>:生产者消费者,无需手动加锁
2️⃣ 线程池饥饿:为什么任务排队不执行?
2.1 什么是线程池饥饿?
线程池饥饿:线程池中的所有线程都被阻塞或占用,导致新任务无法执行,只能排队等待。
现象:
- 吞吐量突然下降
- 响应时间变长
- CPU 占用不高,但任务就是不执行
2.2 线程池饥饿的根本原因
回顾第 2 章讲的线程池知识:
- 线程池有最大线程数(默认几千)
- 线程池注入新线程的速度有限(每秒约 2 个)
- 如果所有线程都被阻塞(
Thread.Sleep、.Wait()、.Result),新任务只能排队
2.3 线程池饥饿演示代码
// 来自 ThreadPoolStarvationDemo.cs// ❌ 错误做法:用 Task.Run 执行同步阻塞操作
for (int i = 0; i < 50; i++)
{int taskId = i;tasks.Add(Task.Run(() =>{Console.WriteLine($"[Task {taskId}] 开始执行");Thread.Sleep(10000); // ❌ 阻塞线程池线程 10 秒Console.WriteLine($"[Task {taskId}] 完成");}));
}await Task.WhenAll(tasks);
运行后你会发现:
- 前几个任务立即执行
- 后面的任务排队等待(线程池饥饿)
- 总耗时远超预期
2.4 使用 dotnet-counters 监控线程池
工具安装:
dotnet tool install --global dotnet-counters
监控线程池指标:
dotnet-counters monitor -p <进程ID> --counters System.Runtime[threadpool-thread-count,threadpool-queue-length]
输出示例:
Name Current Value
[System.Runtime]ThreadPool Queue Length 154 --排队等待执行的任务数量ThreadPool Thread Count 45 -- 线程数量
解读:
ThreadPool Thread Count:当前线程池线程数ThreadPool Queue Length:排队等待的任务数- 如果
Queue Length持续增长,说明线程池饥饿

2.5 如何解决线程池饥饿?
核心原则:不要在 Task.Run 中使用同步阻塞方法!
// ✅ 正确做法:使用异步方法
for (int i = 0; i < 500; i++)
{int taskId = i;tasks.Add(Task.Run(async () =>{Console.WriteLine($"[Task {taskId}] 开始执行");await Task.Delay(10000); // ✅ 异步等待,不阻塞线程Console.WriteLine($"[Task {taskId}] 完成");}));
}await Task.WhenAll(tasks);
其他解决方案:
- 增加线程池最小线程数(临时方案):
ThreadPool.SetMinThreads(100, 100);
- 使用专用线程(适合长时间运行的任务):
var thread = new Thread(() =>
{// 长时间阻塞操作Thread.Sleep(int.MaxValue);
})
{IsBackground = true
};
thread.Start();
- 优化代码,移除同步阻塞:
File.ReadAllText→File.ReadAllTextAsyncHttpClient.GetString→HttpClient.GetStringAsync.Result→await.Wait()→await
3️⃣ 内存泄漏诊断:内存持续增长怎么办?
3.1 什么是内存泄漏?
内存泄漏:对象不再使用,但由于存在引用,GC 无法回收,导致内存持续增长。
常见现象:
- 内存占用持续增长
- GC 频繁触发,但内存不释放
- 最终
OutOfMemoryException
3.2 常见的内存泄漏场景
还记得第 9 章讲的内存泄漏吗?这里我们用生产环境的视角重新审视:
场景 1:事件未取消订阅
// 来自 MemoryLeakDemo.cspublic class EventPublisher
{public event EventHandler<string>? DataReceived;
}public class EventSubscriber
{private readonly byte[] _largeData = new byte[1024 * 1024]; // 1MBpublic EventSubscriber(EventPublisher publisher){publisher.DataReceived += OnDataReceived; // ❌ 订阅事件}// ❌ 忘记取消订阅,导致 EventPublisher 持有 EventSubscriber 的引用
}// 使用
var publisher = new EventPublisher();
for (int i = 0; i < 100; i++)
{var subscriber = new EventSubscriber(publisher);// subscriber 离开作用域,但无法被 GC 回收
}
后果:100 个 EventSubscriber 对象(总计 100MB)无法被 GC 回收。
场景 2:静态字段持有大对象
// ❌ 错误:静态字段在应用程序生命周期内永远存活
private static readonly List<byte[]> _cache = new();public void AddToCache()
{_cache.Add(new byte[1024 * 1024]); // 1MB// 永远不释放
}
场景 3:CancellationTokenSource 未释放
// ❌ 错误:未释放 CancellationTokenSource
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(30)); // 内部注册了 Timer
// 忘记 Dispose,Timer 不会被释放
场景 4:闭包捕获大对象
// ❌ 错误:闭包捕获了不需要的大对象
byte[] largeData = new byte[1024 * 1024];
StringBuilder sb = new StringBuilder();Task.Run(() =>
{// 只使用 sb,但闭包同时捕获了 largeDatasb.Append("data");
});
3.3 使用 dotnet-gcdump 诊断内存泄漏
工具安装:
dotnet tool install --global dotnet-gcdump
诊断步骤:
步骤 1:抓取第一个快照
dotnet-gcdump collect -p <进程ID> -o snapshot1.gcdump
步骤 2:运行一段时间,触发泄漏
让程序运行 5-10 分钟,模拟正常使用。
步骤 3:抓取第二个快照
dotnet-gcdump collect -p <进程ID> -o snapshot2.gcdump
步骤 4:使用 Visual Studio 对比快照
- 打开 Visual Studio(我的是VS2026)
- 文件 → 打开 → 文件
- 选择
snapshot2.gcdump,注意,这里要选择后抓取的gcdump文件 - 在打开的页面的右上侧,
与基线进行比较,选择最开始抓取的snapshot1.gcdump
此时页面上清晰的展现出各个类型的对象数据。
列表里面选择EventSubscriber,下方列表就能看见完整的引用路径。
示例输出:
对象类型 数量增长 大小增长
EventSubscriber +100 +100MB└─ EventHandler<string>└─ EventPublisher._publisher
解读:EventPublisher 通过事件委托持有 EventSubscriber 的引用,导致泄漏。

3.4 使用 Visual Studio 内存分析器
Visual Studio 提供了更强大的实时内存分析工具:
- 调试 → 性能分析器 → .NET 对象分配跟踪
- 启动程序
- 拍摄快照 1
- 执行触发泄漏的操作
- 拍摄快照 2
- 对比快照,查看增长的对象
优势:
- 实时监控
- 可视化 GC Root Path
- 支持筛选和搜索
3.5 如何预防内存泄漏?
核心原则:
- 事件订阅要取消订阅:
// ✅ 在 Dispose 中取消订阅
public void Dispose()
{_publisher.DataReceived -= OnDataReceived;
}
- 使用
using语句释放资源:
// ✅ 自动释放 CancellationTokenSource
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(30));
- 静态字段使用 MemoryCache:
// ✅ 使用 MemoryCache 并设置过期
private static readonly MemoryCache _cache = new(new MemoryCacheOptions
{SizeLimit = 1024 // 限制条目数
});_cache.Set("key", value, new MemoryCacheEntryOptions
{Size = 1,AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});
- 避免闭包捕获大对象:
// ✅ 显式释放不需要的引用
var data = largeData;
largeData = null; // 显式释放
Task.Run(() => Process(data));
4️⃣ CPU 占用过高:如何定位热点方法?
4.1 CPU 占用过高的常见原因
- 死循环(忘记
await) - 低效算法(O(n²) 的循环)
- 频繁的字符串拼接(未用
StringBuilder) - 过度的反射调用
4.2 死循环演示
// 来自 HighCpuDemo.cs// ❌ 错误:忘记 await,导致死循环
public async Task ProcessAsync()
{while (true){var data = await GetDataAsync();Task.Delay(1000); // ❌ 忘记 await,导致死循环}
}
现象:CPU 占用 100%,程序无响应。
4.3 使用 dotnet-trace 诊断 CPU 占用
工具安装:
dotnet tool install --global dotnet-trace
收集性能跟踪:
# 收集 60 秒的 CPU 采样数据
dotnet-trace collect -p <进程ID> --duration 00:01:00
这会生成一个 .nettrace 文件。
4.4 使用 PerfView 分析 CPU 火焰图
工具下载:PerfView
分析步骤:
- 打开 PerfView
- File → Open → 选择
.nettrace文件 - 双击 CPU Stacks
- 查看火焰图,找出 CPU 占用最高的方法
示例输出:
HighCpuDemo.ProcessAsync 95.2%└─ while (true) { ... }
解读:ProcessAsync 方法占用了 95.2% 的 CPU 时间。
4.5 其他 CPU 占用场景
场景 1:低效算法
// ❌ 冒泡排序(O(n²))
for (int i = 0; i < n - 1; i++)
{for (int j = 0; j < n - i - 1; j++){if (array[j] > array[j + 1]){(array[j], array[j + 1]) = (array[j + 1], array[j]);}}
}// ✅ 内置排序(O(n log n))
Array.Sort(array);
场景 2:频繁的字符串拼接
// ❌ 错误:每次拼接都创建新字符串
string result = "";
for (int i = 0; i < 10000; i++)
{result += $"Item {i}|";
}// ✅ 正确:使用 StringBuilder
var sb = new StringBuilder();
for (int i = 0; i < 10000; i++)
{sb.Append($"Item {i}|");
}
string result = sb.ToString();
场景 3:过度的反射调用
// ❌ 错误:每次都用反射调用
var methodInfo = typeof(TestClass).GetMethod("Add");
for (int i = 0; i < 1000000; i++)
{methodInfo.Invoke(testObject, new object[] { i, i + 1 });
}// ✅ 正确:缓存委托
var addDelegate = (Func<int, int, int>)Delegate.CreateDelegate(typeof(Func<int, int, int>), testObject, methodInfo);
for (int i = 0; i < 1000000; i++)
{addDelegate(i, i + 1);
}
5️⃣ Visual Studio 诊断工具:开发者的瑞士军刀
除了命令行工具,Visual Studio 还提供了一套强大的可视化诊断工具。大家平时要学会使用这些东西,能极大的提升工作效率。

5.1 并行堆栈窗口
用途:可视化所有线程的调用栈,快速定位死锁。
使用步骤:
- 启动调试(F5)
- 程序卡住后,点击暂停按钮
- 调试 → 窗口 → 并行堆栈

效果:以树状图显示哪些线程在等待锁。
5.2 任务窗口
用途:查看所有 Task 的状态。
使用步骤:
- 启动调试(F5)
- 调试 → 窗口 → 任务
显示信息:
- Task ID
- 状态(Running、WaitingForActivation、RanToCompletion)
- 位置
5.3 性能分析器
用途:实时监控 CPU、内存、I/O。
使用步骤:
- 调试 → 性能分析器
- 选择分析工具:
- CPU 使用率:找出热点方法
- .NET 对象分配跟踪:找出内存泄漏
- 数据库:找出慢查询
- 点击启动
优势:
- 无需修改代码
- 可视化报告
- 支持历史记录
5.4 诊断工具窗口
用途:调试时自动打开,实时监控。
显示信息:
- CPU 占用
- 内存占用
- 事件(异常、HTTP 请求)
- 网络流量
6️⃣ 诊断清单:生产环境问题速查表
| 问题现象 | 可能原因 | 诊断工具 | 关键命令 |
|---|---|---|---|
| 程序卡死,CPU 低 | 死锁 | dotnet-dump | syncblk、parallelstacks |
| 吞吐量下降 | 线程池饥饿 | dotnet-counters | threadpool-queue-length |
| 内存持续增长 | 内存泄漏 | dotnet-gcdump | 对比快照 |
| CPU 占用 100% | 死循环/低效算法 | dotnet-trace | CPU Stacks 火焰图 |
| 响应时间长 | 慢查询/网络延迟 | dotnet-trace | 追踪请求链路 |
| GC 频繁触发 | 频繁分配 | PerfView | GC Stats |
7️⃣ 实战演练:跟着博客一起诊断
练习 1:诊断死锁
- 运行
ProductionDiagnostics项目,选择选项1 - 程序卡住后,使用
dotnet-dump抓取 dump - 使用
syncblk命令找出死锁的两个线程 - 思考:如何修改代码避免死锁?
练习 2:诊断线程池饥饿
- 运行
ProductionDiagnostics项目,选择选项2 - 在另一个终端使用
dotnet-counters监控线程池 - 观察
threadpool-queue-length的变化 - 对比同步阻塞和异步实现的差异
练习 3:诊断内存泄漏
- 运行
ProductionDiagnostics项目,选择选项3 - 选择一个泄漏场景(比如事件未取消订阅)
- 使用 Visual Studio 内存分析器拍摄快照
- 对比快照,找出泄漏的对象类型
练习 4:诊断 CPU 占用
- 运行
ProductionDiagnostics项目,选择选项4 - 选择一个 CPU 占用场景(比如死循环)
- 使用
dotnet-trace收集性能跟踪 - 使用 PerfView 分析火焰图
8️⃣ 总结:诊断技能是高级开发者的标志
这一章我们学习了生产环境诊断的核心技能:
-
死锁排查:
- 使用
dotnet-dump抓取 dump - 使用
syncblk和parallelstacks定位死锁 - 预防死锁的四大原则
- 使用
-
线程池饥饿:
- 使用
dotnet-counters监控线程池 - 移除同步阻塞,使用异步方法
- 使用
-
内存泄漏:
- 使用
dotnet-gcdump对比快照 - 使用 Visual Studio 分析 GC Root Path
- 四大泄漏场景和预防方法
- 使用
-
CPU 占用过高:
- 使用
dotnet-trace收集性能跟踪 - 使用 PerfView 分析火焰图
- 优化算法、缓存反射、使用 StringBuilder
- 使用
最重要的建议:
- ✅ 平时多练习:不要等到生产环境出问题才学
- ✅ 建立诊断流程:问题现象 → 诊断工具 → 定位原因 → 修复验证
- ✅ 保留诊断数据:dump 文件、trace 文件,用于后续分析
- ✅ 写好日志:结构化日志 + 请求 ID,方便追踪
掌握这些诊断技能,你就能从容应对生产环境的各种"疑难杂症",成为团队中不可或缺的救火队长!
9️⃣ 写在最后
关于.Net高级调试,我可能都还没入门,连菜鸟都算不上。上面写的都是最最基础的东西,因为再往深了写,我也不会。。。。
关于这方面知识,我强烈推荐 一线码农 - 博客园 。如果各位想在这方面下功夫,可以多看看他的博客。
📚 本章配套代码
所有示例代码都在 ProductionDiagnostics 项目中:
DeadlockDemo.cs- 死锁演示ThreadPoolStarvationDemo.cs- 线程池饥饿演示MemoryLeakDemo.cs- 内存泄漏演示HighCpuDemo.cs- CPU 占用过高演示AsyncDeadlockDemo.cs- 异步死锁演示ObjectPoolLeakDemo.cs- 对象池内存泄漏演示
运行项目:
cd ProductionDiagnostics
dotnet run