别只会看日志:C#.NET SOS 调试扩展从 Dump 到内存泄漏排查

简介

线上 .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"这条主线,就足以处理大量生产环境中的内存泄漏、线程卡死和异常现场问题。

参考资料:

相关推荐
yswenli8 小时前
基于C#开发网站视频嗅探下载工具——BrowserVideoGrabber
c#·videodownload·webvideodownload·videograbber
Lost of 程序猿8 小时前
M2 实战:询价与采购订单聚合落地——跨聚合一致性、领域事件与 Outbox
开发语言·c#·asp.net·.net
有梦想的咕噜10 小时前
Newtonsoft.Json (Json.NET) 常用方法汇总
linux·json·.net
Polevne10 小时前
C# GRPC 一元与双向流
linux·算法·c#
XiaoZhenHua981 天前
C#工业机器视觉Recipe配方系统设计:多型号、多相机、多工位参数如何统一管理
c#·软件架构·机器视觉·工业自动化·工业软件·recipe
fujisheng6611 天前
FUI 导航实践:拆开 Layer、History、Coverage 与 Cache 的组合语义
c#·unity3d
淡海水1 天前
10-05-高级-模式匹配-CSharp如何改变与数据结构的交互方式
数据结构·算法·c#·模式匹配
XiaoZhenHua981 天前
C# WinForms + 西门子S7 + SQLite + EPPlus生产数据采集系统架构设计
系统架构·sqlite·c#
Lost of 程序猿1 天前
船岸数据同步链路实战:SendFlag、单调版本号与断网三天后的续传
后端·c#·asp.net