【.NET并发编程 - 20】生产环境诊断实战

20. 生产环境诊断实战:当程序卡死时,你该怎么办?

本章 GitHub 仓库:csharp-concurrency-cookbook ⭐

欢迎 Star 和 Fork!所有诊断示例代码都在 ProductionDiagnostics 项目中。


🎯 本章导读

这一章,我们就来聊聊这个每个开发者都可能遇到的噩梦场景。与前面章节的"如何写好代码"不同,这次我们要学的是"当代码出问题时,如何快速定位和解决"

本章将回答以下核心问题

  • 死锁排查:程序卡死、日志停止输出,如何定位是哪两个线程互相等待?
  • 线程池饥饿:吞吐量突然下降,如何判断是不是线程池线程被耗尽了?
  • 内存泄漏:内存持续增长不释放,如何找到是哪个对象没有被 GC 回收?
  • CPU 占用过高:CPU 100%,如何快速定位是哪个方法在疯狂消耗 CPU?

这些都是生产环境的"疑难杂症",掌握诊断方法,你就能从一个普通开发者进阶为能独当一面的高级开发者

⚠️ 重要提示

  • 本章的示例代码都是故意写错的,用于模拟生产环境问题
  • 建议在测试环境或本地运行这些示例,不要在生产环境直接运行
  • 诊断工具的使用需要一定的练习,建议跟着示例一步步操作

0️⃣ 为什么需要诊断技能?写好代码不就行了吗?

在开始之前,我想先聊聊一个很多初级开发者容易有的误区:

"我的代码没问题,测试也都通过了,应该不会有问题吧?"

现实很残酷

  1. 测试环境和生产环境不一样

    • 测试环境可能只有 10 个并发用户,生产环境是 10000 个
    • 测试环境数据量小,生产环境数据量大
    • 测试环境网络延迟低,生产环境网络不稳定
  2. 有些问题只有在特定条件下才会触发

    • 死锁需要特定的执行顺序
    • 内存泄漏需要长时间运行才能发现
    • 性能问题需要高负载才能暴露
  3. 代码是会变的

    • 你改了一个看似无害的逻辑
    • 同事改了一个依赖的库
    • 框架升级引入了新的 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

步骤 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()

这会显示哪些线程在等待锁。

死锁3

1.5 如何预防死锁?

看到这里,你可能会说:"好,我知道怎么诊断了,但怎么避免死锁?"

预防死锁的四大原则

  1. 避免嵌套锁(最简单有效):
// ❌ 错误:嵌套锁
lock (_lock1)
{lock (_lock2){// 危险!}
}// ✅ 正确:只用一个锁
lock (_lock)
{// 安全
}
  1. 统一加锁顺序
// ✅ 所有地方都先获取 Lock1,再获取 Lock2
lock (_lock1)
{lock (_lock2){// 安全}
}
  1. 使用超时机制
// ✅ 使用 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 超时");
}
  1. 使用更高级的同步原语
    • 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);

其他解决方案

  1. 增加线程池最小线程数(临时方案):
ThreadPool.SetMinThreads(100, 100);
  1. 使用专用线程(适合长时间运行的任务):
var thread = new Thread(() =>
{// 长时间阻塞操作Thread.Sleep(int.MaxValue);
})
{IsBackground = true
};
thread.Start();
  1. 优化代码,移除同步阻塞
    • File.ReadAllTextFile.ReadAllTextAsync
    • HttpClient.GetStringHttpClient.GetStringAsync
    • .Resultawait
    • .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 对比快照

  1. 打开 Visual Studio(我的是VS2026)
  2. 文件打开文件
  3. 选择 snapshot2.gcdump注意,这里要选择后抓取的gcdump文件
  4. 在打开的页面的右上侧,与基线进行比较,选择最开始抓取的snapshot1.gcdump

此时页面上清晰的展现出各个类型的对象数据。

列表里面选择EventSubscriber,下方列表就能看见完整的引用路径。

示例输出

对象类型              数量增长    大小增长
EventSubscriber       +100       +100MB└─ EventHandler<string>└─ EventPublisher._publisher

解读EventPublisher 通过事件委托持有 EventSubscriber 的引用,导致泄漏。

内存泄露2

3.4 使用 Visual Studio 内存分析器

Visual Studio 提供了更强大的实时内存分析工具:

  1. 调试性能分析器.NET 对象分配跟踪
  2. 启动程序
  3. 拍摄快照 1
  4. 执行触发泄漏的操作
  5. 拍摄快照 2
  6. 对比快照,查看增长的对象

优势

  • 实时监控
  • 可视化 GC Root Path
  • 支持筛选和搜索

3.5 如何预防内存泄漏?

核心原则

  1. 事件订阅要取消订阅
// ✅ 在 Dispose 中取消订阅
public void Dispose()
{_publisher.DataReceived -= OnDataReceived;
}
  1. 使用 using 语句释放资源
// ✅ 自动释放 CancellationTokenSource
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(30));
  1. 静态字段使用 MemoryCache
// ✅ 使用 MemoryCache 并设置过期
private static readonly MemoryCache _cache = new(new MemoryCacheOptions
{SizeLimit = 1024 // 限制条目数
});_cache.Set("key", value, new MemoryCacheEntryOptions
{Size = 1,AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});
  1. 避免闭包捕获大对象
// ✅ 显式释放不需要的引用
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

分析步骤

  1. 打开 PerfView
  2. FileOpen → 选择 .nettrace 文件
  3. 双击 CPU Stacks
  4. 查看火焰图,找出 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 还提供了一套强大的可视化诊断工具。大家平时要学会使用这些东西,能极大的提升工作效率。

image

5.1 并行堆栈窗口

用途:可视化所有线程的调用栈,快速定位死锁。

使用步骤

  1. 启动调试(F5)
  2. 程序卡住后,点击暂停按钮
  3. 调试窗口并行堆栈

image

效果:以树状图显示哪些线程在等待锁。

5.2 任务窗口

用途:查看所有 Task 的状态。

使用步骤

  1. 启动调试(F5)
  2. 调试窗口任务

显示信息

  • Task ID
  • 状态(Running、WaitingForActivation、RanToCompletion)
  • 位置

5.3 性能分析器

用途:实时监控 CPU、内存、I/O。

使用步骤

  1. 调试性能分析器
  2. 选择分析工具:
    • CPU 使用率:找出热点方法
    • .NET 对象分配跟踪:找出内存泄漏
    • 数据库:找出慢查询
  3. 点击启动

优势

  • 无需修改代码
  • 可视化报告
  • 支持历史记录

5.4 诊断工具窗口

用途:调试时自动打开,实时监控。

显示信息

  • CPU 占用
  • 内存占用
  • 事件(异常、HTTP 请求)
  • 网络流量

6️⃣ 诊断清单:生产环境问题速查表

问题现象 可能原因 诊断工具 关键命令
程序卡死,CPU 低 死锁 dotnet-dump syncblkparallelstacks
吞吐量下降 线程池饥饿 dotnet-counters threadpool-queue-length
内存持续增长 内存泄漏 dotnet-gcdump 对比快照
CPU 占用 100% 死循环/低效算法 dotnet-trace CPU Stacks 火焰图
响应时间长 慢查询/网络延迟 dotnet-trace 追踪请求链路
GC 频繁触发 频繁分配 PerfView GC Stats

7️⃣ 实战演练:跟着博客一起诊断

练习 1:诊断死锁

  1. 运行 ProductionDiagnostics 项目,选择选项 1
  2. 程序卡住后,使用 dotnet-dump 抓取 dump
  3. 使用 syncblk 命令找出死锁的两个线程
  4. 思考:如何修改代码避免死锁?

练习 2:诊断线程池饥饿

  1. 运行 ProductionDiagnostics 项目,选择选项 2
  2. 在另一个终端使用 dotnet-counters 监控线程池
  3. 观察 threadpool-queue-length 的变化
  4. 对比同步阻塞和异步实现的差异

练习 3:诊断内存泄漏

  1. 运行 ProductionDiagnostics 项目,选择选项 3
  2. 选择一个泄漏场景(比如事件未取消订阅)
  3. 使用 Visual Studio 内存分析器拍摄快照
  4. 对比快照,找出泄漏的对象类型

练习 4:诊断 CPU 占用

  1. 运行 ProductionDiagnostics 项目,选择选项 4
  2. 选择一个 CPU 占用场景(比如死循环)
  3. 使用 dotnet-trace 收集性能跟踪
  4. 使用 PerfView 分析火焰图

8️⃣ 总结:诊断技能是高级开发者的标志

这一章我们学习了生产环境诊断的核心技能:

  1. 死锁排查

    • 使用 dotnet-dump 抓取 dump
    • 使用 syncblkparallelstacks 定位死锁
    • 预防死锁的四大原则
  2. 线程池饥饿

    • 使用 dotnet-counters 监控线程池
    • 移除同步阻塞,使用异步方法
  3. 内存泄漏

    • 使用 dotnet-gcdump 对比快照
    • 使用 Visual Studio 分析 GC Root Path
    • 四大泄漏场景和预防方法
  4. 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