简介
线上 .NET 程序出现问题时,最麻烦的往往不是"报错了",而是:
- 进程内存一直涨,却不知道是哪类对象没有释放;
- CPU 飙到 100%,日志里只有几行普通请求记录;
- 接口没有异常,但线程全部卡住;
- 进程已经退出,只留下一个 Dump 文件;
- async/await 层层等待,普通调用栈看不出真正的阻塞点。
这类问题需要直接查看 CLR 的运行时状态:托管堆里有哪些对象、线程当前停在哪里、某个对象被谁引用、异常对象保存了什么信息。
SOS 就是专门做这件事的 .NET 调试扩展。它不是 NuGet 包,也不是应用程序中的业务 API,而是一组让 WinDbg、LLDB、dotnet-dump 看懂 CLR 内部结构的调试命令。
一句话概括:
dotnet-dump 负责采集或打开 Dump,SOS 负责分析托管堆、线程、对象、异常和 GC 状态。
本文通过一个故意制造内存泄漏的控制台程序,演示从启动程序、采集 Dump,到使用 dumpheap、dumpobj、gcroot 找到静态集合的完整过程;再补充高 CPU、死锁、异常、异步任务和 GC 排查时最常用的命令。
SOS、Dump、dotnet-dump、WinDbg 到底是什么关系
几个名字经常混在一起,可以先拆开看:
text
.NET 应用
↓
运行时进程
↓ 采集
Dump 文件
↓ 打开
dotnet-dump / WinDbg / LLDB
↓ 调用
SOS 命令
↓ 读取
CLR、GC、线程、托管对象
SOS
SOS 通常解释为 Son of Strike,是 CLR 调试扩展。它能够读取运行时维护的数据结构,查看托管代码和托管对象。
SOS 能分析实时进程,也能分析 Dump。生产环境更常见的方式是先抓取 Dump,再在隔离机器上离线分析,避免长时间占用线上进程。
Dump
Dump 是某一时刻的进程快照,可能包含:
- 托管堆对象和字段;
- 线程及其寄存器、调用栈;
- 加载的程序集和模块;
- 异常对象;
- 部分原生内存和运行时数据。
Dump 可能包含密码、Token、连接串、用户数据等敏感信息。采集、传输和保存都需要按照生产数据安全要求处理。
dotnet-dump
dotnet-dump 是 .NET CLI 诊断工具,支持 Windows、Linux 和 macOS,不依赖完整的原生调试器。它可以列出 .NET 进程、采集 Dump,也可以进入交互式分析环境执行 SOS 命令。
它不是原生调试器,所以不擅长分析原生栈;纯托管内存、GC、线程和异常问题,通常已经够用。
WinDbg 和 LLDB
WinDbg 是 Windows 上功能很强的原生调试器,LLDB 常用于 Linux 和 macOS。加载 SOS 后,它们可以分析托管和原生两层状态,适合混合模式、崩溃转储和更底层的调试。
安装工具
安装 dotnet-dump
shell
dotnet tool install --global dotnet-dump
已安装过时可以升级:
shell
dotnet tool update --global dotnet-dump
dotnet-dump --version
安装 dotnet-sos
dotnet-dump 已经包含 SOS 分析能力。使用 LLDB 调试实时进程或 Dump 时,可以额外安装 dotnet-sos:
shell
dotnet tool install --global dotnet-sos
dotnet-sos install
在 Linux 和 macOS 上,dotnet-sos install 会配置 LLDB 的自动加载;较老版本的 Windows 调试器可能需要手动加载:
text
.load %USERPROFILE%\\.dotnet\\sos\\sos.dll
现代 Windows 调试工具通常已经集成 .NET 调试扩展,但遇到 SOS 命令不可用时,仍然需要检查扩展是否成功加载。
两种命令写法
同一条 SOS 命令,在不同宿主中的写法略有区别:
| 宿主 | 写法 | 示例 |
|---|---|---|
| dotnet-dump analyze | 直接输入命令 | dumpheap -stat |
| WinDbg / CDB | 前面加 ! | !dumpheap -stat |
| LLDB | sos 加命令,常用命令也有别名 | sos dumpheap -stat |
下面的分析流程默认使用 dotnet-dump analyze,所以示例命令不带 !。切换到 WinDbg 时,把命令改成 !dumpheap -stat 这种形式即可。
进入分析环境后,先执行:
text
> help
> soshelp
> eeversion
help 查看宿主帮助,soshelp 查看 SOS 命令,eeversion 查看目标运行时和 SOS 版本信息。
高频命令速查
| 命令 | 作用 | 常用写法 |
|---|---|---|
| clrstack | 查看当前线程的托管调用栈 | clrstack -a |
| clrthreads | 列出托管线程 | clrthreads |
| dumpstack | 查看调用栈 | dumpstack |
| eestack | 查看所有线程的栈 | eestack |
| dumpheap | 查看 GC 托管堆 | dumpheap -stat |
| dumpobj / do | 查看对象字段和值 | dumpobj 000001... |
| gcroot | 查找对象的引用根 | gcroot 000001... |
| eeheap | 查看 CLR 和 GC 堆 | eeheap -gc |
| dumpmt | 查看 MethodTable | dumpmt 00007... |
| dumpmd | 查看 MethodDesc | dumpmd 00007... |
| dumpil | 查看方法 IL | dumpil 00007... |
| printexception / pe | 查看异常对象 | pe 000001... |
| syncblk | 查看同步块和锁 | syncblk |
| gchandles | 查看 GCHandle | gchandles |
| finalizequeue | 查看终结器队列 | finalizequeue |
| threadpool | 查看线程池状态 | threadpool |
| threadpoolqueue | 查看线程池队列 | threadpoolqueue |
| taskstate | 查看 Task 状态 | taskstate 000001... |
| dumpasync | 查看异步状态机 | dumpasync |
不同 .NET 版本、宿主和 SOS 版本支持的命令可能略有差异。命令不可用时,先运行 soshelp 命令名,不要直接套用其他机器的输出格式。
实战一:用 SOS 定位静态集合造成的内存泄漏
先写一个故意泄漏的程序。LeakStore 是静态 List,只添加对象,不移除对象。静态字段属于 GC Root 的一部分,因此列表中的对象一直可达,GC 没有理由回收它们。
创建 Demo 项目
shell
dotnet new console -n SosLeakDemo
cd SosLeakDemo
将 Program.cs 替换为:
csharp
using System;
using System.Collections.Generic;
using System.Threading;
namespace SosLeakDemo;
public static class Program
{
private static readonly List<LeakItem> LeakStore = new();
public static void Main()
{
Console.WriteLine($"PID: {Environment.ProcessId}");
Console.WriteLine("开始分配对象,按 Ctrl+C 结束程序。");
while (true)
{
LeakStore.Add(new LeakItem
{
Id = Guid.NewGuid().ToString("N"),
Buffer = new byte[1024 * 100]
});
Console.WriteLine($"对象数量:{LeakStore.Count}");
Thread.Sleep(100);
}
}
}
public sealed class LeakItem
{
public string Id { get; init; } = string.Empty;
public byte[] Buffer { get; init; } = Array.Empty<byte>();
}
编译并运行:
shell
dotnet run -c Release
记录输出中的 PID,例如:
text
PID: 24816
开始分配对象,按 Ctrl+C 结束程序。
对象数量:1
对象数量:2
让程序运行一段时间后,进程中会累积大量 LeakItem 和 byte\[\] 对象。
采集 Dump
另开一个终端执行:
shell
dotnet-dump ps
dotnet-dump collect -p 24816 -o leak.dmp
dotnet-dump ps 用于查看当前 .NET 进程,-p 指定进程 ID,-o 指定输出文件。
采集 Dump 可能暂停目标进程一小段时间。生产环境应选择合适的 Dump 类型和业务低峰期,并确认磁盘空间充足。
打开 Dump
shell
dotnet-dump analyze leak.dmp
进入提示符后,先看运行时版本:
text
> eeversion
版本信息很重要。Dump、DAC(Data Access Component)、SOS 最好来自兼容的运行时版本;版本不匹配时,可能出现命令失败、字段解析错误或输出不完整。
第一步:查看托管堆统计
text
> dumpheap -stat
输出是按类型汇总的表格,常见列包括:
text
MT Count TotalSize Class Name
00007... 12000 1440000 SosLeakDemo.LeakItem
00007... 12000 1200000 System.Byte[]
先关注两个指标:
- Count 很高:对象数量异常;
- TotalSize 很高:对象总占用异常。
这里看到 LeakItem 和 System.Byte\[\] 都在增长,说明泄漏对象和它持有的缓冲区都值得继续追踪。
dumpheap -stat 只提供线索,不能单独证明内存泄漏。缓存、连接池和正常的长生命周期对象也可能数量较多,下一步要查引用链。
第二步:按类型筛选对象
可以用类型名筛选:
text
> dumpheap -type SosLeakDemo.LeakItem
也可以先从统计表中复制 LeakItem 的 MethodTable 地址,再按 MethodTable 筛选:
text
> dumpheap -mt 00007ff812345678
输出中会列出对象地址:
text
Address MT Size
000001f012340000 00007ff812345678 64
000001f012340040 00007ff812345678 64
复制一个对象地址,例如 000001f012340000,继续查看对象内容。
第三步:查看对象字段
text
> dumpobj 000001f012340000
也可以使用短别名:
text
> do 000001f012340000
输出会显示类型、大小、MethodTable 和字段。Id 会指向一个字符串对象,Buffer 会指向一个 byte\[\] 对象。再次执行 dumpobj 可以查看字段指向的对象。
第四步:查找 GC Root
text
> gcroot 000001f012340000
关键输出通常类似:
text
HandleTable:
-> 000001f0...
-> 000001f0... SosLeakDemo.Program
-> 000001f0... System.Collections.Generic.List<SosLeakDemo.LeakItem>
-> 000001f0... SosLeakDemo.LeakItem
不同运行时版本的输出格式可能不同,但解读方向一致:
text
GC Root
→ Program 的静态字段 LeakStore
→ List<LeakItem>
→ LeakItem 实例
这条链说明对象仍然可达,根因不是 GC 失效,而是静态列表一直持有对象。修复方式不是手动频繁调用 GC.Collect,而是修正持有关系,例如在对象不再需要时移除,限制缓存容量,或改用有过期策略的缓存组件。
第五步:查看 GC 堆总体情况
text
> eeheap -gc
这个命令可以观察各个 GC 堆的占用情况。需要结合多次 Dump 或实时指标判断趋势:单个时间点的 Gen2 较大,不一定就是泄漏;持续增长且 Full GC 后仍不下降,才更值得怀疑长期存活对象。
实战二:分析高 CPU 线程
高 CPU 问题的核心是找出"正在消耗 CPU 的线程"和它当前执行的代码。
先采集 Dump:
shell
dotnet-dump collect -p <PID> -o cpu.dmp
dotnet-dump analyze cpu.dmp
在分析环境中执行:
text
> clrthreads
> eestack
也可以查看所有线程的托管调用栈:
text
> clrstack -all
重点观察:
- 多个线程是否都卡在同一个业务方法;
- 是否有大量线程在同步等待或锁等待;
- 是否出现高频循环、正则解析、JSON 序列化等热点路径;
- 是否大量线程都在 ThreadPool 工作项中,提示线程池压力。
Dump 是瞬时快照,单个 Dump 只能说明"这一刻线程在哪里"。高 CPU 分析通常连续采集两到三个间隔几秒的 Dump,对比相同线程和相同栈是否持续出现;同时配合 dotnet-counters 确认 CPU 趋势。
实战三:分析异常和崩溃现场
打开崩溃 Dump 后,可以先查看线程和栈:
text
> clrthreads
> clrstack -all
切换到异常线程后,使用:
text
> printexception
如果已经拿到异常对象地址:
text
> pe 000001f012345678
重点关注:
- 异常类型;
- Message;
- InnerException;
- 托管堆栈;
- 是否存在多个相同异常对象。
如果 clrstack 没有源文件名和行号,不一定代表栈坏了,通常是符号文件不完整。SOS 不能凭空恢复不存在的 PDB 信息,需要准备与发布版本匹配的符号文件,并在调试器中配置符号路径。
实战四:死锁、锁竞争和线程池阻塞
可以用下面的命令组合观察线程和同步块:
text
> clrthreads
> syncblk
> clrstack -all
分析思路是:
text
线程 A 持有 lockA,等待 lockB
线程 B 持有 lockB,等待 lockA
syncblk 可以提供同步块相关信息,clrstack -all 可以显示线程当前停在哪个 lock、Monitor.Enter 或等待调用上。
如果没有明显的锁互等,还要排查同步阻塞异步代码:
csharp
var result = GetDataAsync().Result;
GetDataAsync().Wait();
这类写法可能造成线程池线程长期阻塞,表现为请求排队、吞吐下降甚至"看起来像死锁"。SOS 可以帮助确认线程停在等待位置,但最终还需要回到代码和线程池指标验证根因。
实战五:查看 async/await 和 Task
异步方法编译后会形成状态机,源码里的方法名和运行时栈并不总是一一对应。现代 SOS 提供了与异步状态相关的命令:
text
> dumpasync
> threadpool
> threadpoolqueue
dumpasync 用于查找异步状态机,threadpool 查看线程池状态,threadpoolqueue 查看排队工作项。不同运行时和 SOS 版本对 dumpasync 的支持可能不同,命令无输出时可以结合 clrstack -all、Task 对象和线程池状态分析。
定位到某个 Task 地址后,可以尝试:
text
> taskstate 000001f012345678
> dumpobj 000001f012345678
异步问题排查常见关注点:
- Task 是否处于 WaitingForActivation;
- 是否有大量 Task 长时间等待外部 IO;
- 是否在线程池线程中调用了 Result 或 Wait;
- 是否存在未观察的异常;
- 是否是生产者持续提交任务,消费者处理速度不足。
MethodTable、MethodDesc 和对象地址怎么理解
SOS 输出里经常出现三种地址:
text
对象地址 → 某个具体实例
MethodTable → 某种运行时类型的方法表
MethodDesc → 某个具体方法的运行时描述
例如:
text
dumpheap -stat
↓ 找到类型的 MT
dumpheap -mt <MT>
↓ 找到对象地址
dumpobj <对象地址>
查看方法信息时可以使用:
text
> dumpmt -MD <MethodTable地址>
> dumpmd <MethodDesc地址>
> dumpil <MethodDesc地址>
这些命令更适合进一步研究 JIT、虚方法、泛型和运行时方法分派。普通内存泄漏排查通常只需要 dumpheap、dumpobj 和 gcroot。
Dump 类型和分析限制
不是所有 Dump 都包含相同的信息。Mini Dump 体积更小,但缺少部分内存页和运行时数据,某些 SOS 命令可能失败;完整 Dump 文件较大,却更适合堆对象和引用链分析。
使用 dotnet-dump 时需要注意:
- Dump 文件可能非常大,采集前确认磁盘空间;
- 采集过程可能短暂暂停进程;
- 容器环境需要确认诊断权限和目录可写;
- 采集结果包含进程内存,必须妥善保护;
- SOS、运行时和 DAC 版本尽量匹配;
- dotnet-dump 不负责完整的原生栈调试;
- Native AOT、Mono 和特殊宿主的支持范围可能不同。
在 Linux 容器中,进程能正常运行不代表一定能成功采集 Dump。常见原因包括容器权限不足、诊断端口不可访问、运行时文件缺失或工具架构不匹配。先检查 dotnet --info、进程身份、运行时目录和工具版本,再判断是否需要换到同版本的分析环境。
一套实用的排查顺序
内存持续增长
text
dotnet-counters 观察趋势
↓
dotnet-dump collect
↓
dumpheap -stat
↓
dumpheap -type <可疑类型>
↓
dumpobj <对象地址>
↓
gcroot <对象地址>
↓
定位静态字段、缓存、事件、Timer 或 Task 引用
高 CPU
text
确认 CPU 趋势
↓
间隔采集多个 Dump
↓
clrthreads
↓
clrstack -all / eestack
↓
对比重复出现的热点栈
卡死或请求超时
text
clrthreads
↓
syncblk
↓
clrstack -all
↓
threadpool / threadpoolqueue
↓
区分锁互等、同步阻塞、线程池饥饿和外部 IO 等待
崩溃或未处理异常
text
clrthreads
↓
clrstack -all
↓
printexception / pe
↓
检查 InnerException、符号和发布版本
SOS 不适合解决什么问题
SOS 很强,但不是所有性能问题都应该从 SOS 开始:
- 需要长期趋势和计数器时,使用 dotnet-counters;
- 需要时间线、CPU 样本和 IO 事件时,使用 dotnet-trace;
- 需要 GC 事件细节时,使用 dotnet-gcdump、Trace 或专门的 Profiler;
- 需要原生内存、C/C++ 栈和系统调用时,使用 WinDbg、LLDB 或操作系统工具;
- 需要查看业务请求链路时,使用日志、指标和分布式追踪。
比较稳妥的诊断组合是:
text
指标发现异常
+ 日志提供业务上下文
+ Trace 提供时间线
+ Dump + SOS 提供某一时刻的运行时现场
常见误区
看到 Gen2 大就认定内存泄漏
Gen2 中有大量长生命周期对象并不一定异常。需要观察多次采样、Full GC 后内存是否下降,再用 gcroot 查具体对象的持有链。
只执行 dumpheap,不查 gcroot
dumpheap -stat 只能告诉类型数量和大小,不能说明对象为什么活着。真正定位引用关系,通常要继续执行 dumpobj 和 gcroot。
用错误版本的 SOS 分析 Dump
运行时版本、架构、SOS 和 DAC 不匹配时,命令可能失败或输出异常。先执行 eeversion,再准备匹配的分析环境。
把 dotnet-dump 当成完整原生调试器
dotnet-dump 适合跨平台托管诊断,但不支持完整原生栈能力。混合托管/原生崩溃需要 WinDbg 或 LLDB。
直接把生产 Dump 发到公共位置
Dump 可能包含用户数据、密钥和请求内容。应当加密存储、限制访问、设置过期时间,分析结束后按安全流程清理。
看到对象多就立刻修改代码
先记录对象类型、数量、大小、GC 代际和根引用,再结合业务生命周期判断。缓存、连接池、单例和线程状态对象本来就可能长期存活。
总结
SOS 的价值在于把"程序现在到底卡在哪里、哪些对象还活着、谁在持有它们"变成可以查询的运行时数据。
text
采集现场:dotnet-dump collect
打开现场:dotnet-dump analyze
看线程:clrthreads、clrstack、eestack
看堆:dumpheap、eeheap
看对象:dumpobj
找根引用:gcroot
看异常:printexception
看锁和线程池:syncblk、threadpool
内存问题先看 dumpheap -stat,再用 gcroot 证明引用链;高 CPU 需要多个 Dump 对比调用栈;死锁要结合 syncblk 和线程栈;异步卡顿要同时查看 Task、线程池和同步阻塞代码。
SOS 学习曲线不低,但核心命令并不多。掌握"采集 Dump → 看统计 → 找对象 → 查 GC Root"这条主线,就足以处理大量生产环境中的内存泄漏、线程卡死和异常现场问题。
参考资料: