捕获ADPlus CLR崩溃
我想讨论一个我们都非常熟悉的场景。在过去的一年里你一直在拼命工作,在过去的几个月里,你甚至在晚上和周末工作。管理层对你的团队给予两周的休息,以感谢你的努力。但是现在你回到了办公室,你听到了来自你的技术支持部门的流言,说有些情况下你的应用程序因为神秘的原因在现场崩溃。你是做什么的?
应用程序恰好是用通过注册的未经处理的异常筛选器生成的AppDomain.UnhandledException事件。因此,您至少知道应用程序因InvalidCastException而失败,但是您无法想象为什么会发生这种情况。
如果您可以在受影响的系统上进行实时调试,那不是很好吗?除非您在现场为您的客户工作,或者您的软件在笔记本电脑上,并且您的客户愿意将其发送给您,否则我怀疑您是否会获得此机会。您需要的是一个工具来捕获应用程序失败时的状态。然后客户可以捕获这些信息并将其发送给您。
输入ADPlus。ADPlus是Debugging Tools for Windows包中的一个免费工具,它为CDB调试器编写脚本,允许您捕获系统上一个或多个进程的转储。它还具有以下优点:
- ADPlus可以监视桌面应用程序、服务等。
- ADPlus可以监视系统上的多个进程。当它收集这些进程的转储时,它会同时冻结和转储它们。这对于跟踪进程间通信的问题至关重要。
- ADPlus支持xcopy部署,这意味着客户不需要通过Windows安装程序等来安装任何东西。这将最大限度地减少机器上的配置更改,这对客户来说是一种音乐。
注意:尽管ADPlus可以xcopy安装,但您仍然必须通过Windows安装程序安装Windows调试工具包,因为这是微软发布它的唯一方式。但是,一旦安装了一次Windows调试工具,就可以xcopy将ADPlus或整个Windows调试工具包部署到另一台计算机上。事实上,在开发过程中,我发现将开发工具签入源存储库非常方便。Windows调试工具支持这一点,因为它是xcopy可安装的。
对于熟悉Windows Installer的用户,您可以使用msi执行管理安装,以便调试Windows工具,这将允许您提取文件,而无需在计算机上实际安装软件包,例如:msiexec /a dbg_x86_6.11.1.404.msi。
综上所述,让我们看看ADPlus如何帮助您诊断.NET应用程序的问题。
示例应用程序
我将引用我放在一起的C#3.0示例应用程序来演示如何使用ADPlus捕获.NET应用程序中未经处理的异常。代码如下:
usingSystem;usingSystem.Linq;usingSystem.Runtime.Serialization;classA
{public voidSaySomething() {
Console.WriteLine("Yeah, Peter....");throw newBadDesignException();
}
}classB : A
{
}classC
{
}classEntryPoint
{static voidMain() {
DoSomething();
}static voidDoSomething() {
Func<int, object> generatorFunc = (x) =>{if( x == 7) {return newC();
}else{return newB();
}
};var collection = from i in Enumerable.Range( 0, 10)selectgeneratorFunc(i);//Let's iterate over each of the items in the collection// //ASSUMING THEY ARE ALL DERIVED FROM A !!!! foreach( var item incollection ) {
A a=(A) item;try{
a.SaySomething();
}catch( BadDesignException ) {//Swallow these here. The programmer chose to//use exceptions for normal control flow which//is *very* poor design. }
}
}
}public classBadDesignException : Exception
{publicBadDesignException() { }public BadDesignException( string msg ) : base( msg ) { }public BadDesignException( string msg, Exception x ) : base( msg, x ) { }protectedBadDesignException( SerializationInfo si,
StreamingContext ctx )
:base( si, ctx ) { }
}
关于调试内存泄漏的范例
在本机代码中,内存由调用Malloc()/Free()的程序(或类似的api)显式管理。在托管代码中,有一个垃圾收集器(GC)。
GC并不意味着您不再有内存泄漏。它只意味着运行时在运行时检测到对象不再可访问后的某个任意点为您调用Free()。这有效地在安全方面出错(不能释放对象),以避免悬空指针问题,这通常会导致崩溃。因此,如果对象总是可访问的,即使GC也无法释放它,因此会出现内存泄漏。发生这种情况的一种常见方法是使用一些委托或静态引用。这是一个常见的关于主题的文章,所以在基本问题上我没有什么要补充的。查看Maoni或Chris Lyon的博客,了解GC的优点。
我想说的是,这是所产生的bug的一种范式转变。
本机内存管理中的常见错误:
Bug | Effect |
calling Free() twice on the same memory | Crash, undefined behavior. |
never calling Free() (or Release()) | Memory leak |
Using a pointer after the original memory was freed (dangling pointer). | Crash, Using undefined memory, arbitrary undefined behavior |
注意:这些问题的范围已经超出了内存的使用范围,但由于这是一篇博客文章,而不是一篇论文,所以我的范围是有限的。(例如,如果我们使用C++析构函数而不是Free(),C++ dor会释放OS资源)
GC环境中的常见错误(托管代码):
Bug | Effect |
Not releasing a reference | Memory leak |
Finalizer called at random times | Native resource not released when expected. This could be a resource leak. Or it could lock resources longer than expected, preventing others from using them)The finalizer can't be sure what state it's called in. |
由于在GC环境中不能调用Free(),因此您不必再担心整个类的bug。
从一个角度来看,GCs基本上将bug从崩溃降级为内存泄漏。从debugger+工具的角度来看,用户代码中仍然存在bug,因此用户仍然需要工具来调试它们。但是,针对本机内存泄漏的传统工具在托管内存泄漏的世界中发挥得并不好。
malloc/free错误很难,因为罪魁祸首可能离失败点很远。例如,当Free()被调用两次时,您可能会发现第二个Free()崩溃。但是现在您可能需要找到对Free()的第一个调用,它可能发生在过去的某个任意时刻。一般来说,诊断非托管内存泄漏通常需要日志记录工具来跟踪malloc/free调用并对其进行分析;或者采用捕捉悬空指针的奇特内存页保护方案。(参见UMDH或PageHeap)
托管内存泄漏在概念上很容易,因为您可以通过检查当前堆,从单个快照中及时静态诊断它们。不幸的是,ICorDebug没有提供这个功能,所以这里没有很好的VS集成。实际上,您需要使用SOS。
总而言之:从bug的角度来看,从手动内存管理到GC的转变有两大好处:
-它降低了bug的严重性(从崩溃/未定义的行为到内存泄漏)
-它产生的bug更容易诊断,但需要改变工具范式来解决这些新的bug。
调试器不应该改变行为
理想情况下,无论是否附加了调试器,应用程序都会执行相同的操作。这源于非常实际的动机:
- bug通常首先出现在调试器之外(一些测试失败),然后您只想在调试器下重新运行测试来重新生成问题。如果调试器更改了行为,这将妨碍您重新编程的能力。
- 另一方面,开发人员可以在IDE下开发代码,并通过在调试器下检查代码来确保代码正常工作。如果调试器引入了隐藏缺陷的行为更改,那么开发人员将在这里忽略它。
- 如果应用程序的行为只发生在调试器下,人们会将其行为归咎于调试器。例如,如果一个应用程序只在调试器下抛出异常,那么人们会认为调试器导致了异常,或者调试器已损坏并错误地报告了异常。(我们发现了很多这样的缺陷)。这种错误的指责会阻碍真正的问题得到解决。
- 行为差异会削弱人们对调试器的信心。
- 调试时,调试器是否附加到正在运行的进程,或者调试对象是否从调试器下启动都不重要。行为上的改变会在这两种情况之间造成一个楔子。这对于只能附加的调试器场景特别重要(例如,调试ASP.Net或SQL)
那么,为什么应用程序在托管调试器下的执行方式会有所不同呢?
- 用户检查:最大的罪魁祸首:程序可以显式调用System.Diagnostics.Debugger.IsAttached以询问是否附加了托管调试器,然后行为不同。(win32 API IsDebuggerPresent()同样检查是否附加了本机调试器。)。这是最容易引起痛苦的方法。例如,如果附加了托管调试器,WinForms将显式使用不同的“可调试”WndProc。这个可调试的wndproc在用户回调(不可调试的wndproc没有)周围有一个额外的try-catch,用于通知用户回调是否抛出异常。另一个最受欢迎的方法似乎是在附加了调试器以通知用户时抛出异常。
- CLR/JIT检查:ICorDebug允许调试器更改实时编译器的codegen选项(例如禁用优化)。这会导致从IL生成不同的本机代码,这肯定会改变行为。如果附加了调试器,v1.0和v1.1clrs中的jit实际上会自动生成可调试代码。这是个非常糟糕的主意。在v2.0中,JIT显式地不知道是否附加了调试器,尽管调试器可以显式地请求JIT生成可调试的代码。
- 操作系统检查:在极少数情况下,操作系统可能会处理不同的事情。例如,在Windows上,如果附加了本机(或互操作)调试器,则不会针对未处理的异常执行筛选器。(自己试试看)
- 利用非确定性:应用程序执行的某些部分是不确定的,例如定时问题和对机器范围内状态的任何引用(例如页面共享)。例如,在多线程应用程序中,调试器肯定会影响计时,这可能会暴露(或隐藏)现有的竞争条件。我个人觉得这比我想的要普遍得多。正因为这个原因,我不得不调试太多只会在调试器之外的优化版本上重新生成的bug。
- 调试器检查:以上所有原因都假定调试器行为良好。然而,作为API的产品,我们不能阻止API的使用者做改变debuggee行为的“愚蠢的”事情。例如:
.在托管进程下启动:应用程序通常在自己的进程中启动。有人可以编写一个调试器,在宿主进程内部启动调试对象。这是调试和非调试的区别。
.Func eval:属性求值(Func eval)的每个实例都是只在调试器下运行的代码。如果函数求值有任何副作用,则可以更改调试对象的行为。如果调试器在不提示用户的情况下执行函数求值,这一点尤其重要。
我觉得CLR调试器团队对这个问题太天真了。幸运的是,后面版本中采取了很多伟大的措施来解决这个问题:
- 已经认识到这一点很重要。VS对手帮助我们明确了这一点。
- 已经明确地消除了任何依赖于是否附加调试器的非调试器CLR行为。如前所述,JIT将不再自动生成可调试代码。我们现在还将始终跟踪jit的调试信息(jit生成的IL-->Native映射)。
- 已经尝试识别用户呼叫的常见模式调试器.IsAttached然后让ICorDebug提供其他解决方案,这些解决方案不需要调试人员有不同的行为。例如,Winforms具有可调试回调,因为它们希望在异常跨越回调和处理程序之间的边界时通知用户。如果ICorDebug只是(a)意识到这个边界是重要的,并且(b)提供了一个异常将在其上展开的通知,那么Winforms就永远不需要添加try-catch。
仅通过转储来排除内存泄漏
有时我们会遇到这样一个场景:一个进程正在使用大量内存,而此时我们能够获得的唯一数据就是用户转储。通常,来自umdh或xperf等工具的数据会更好,因为它们提供一段时间内的内存使用数据,并且可以包括调用堆栈信息。但是,umdh需要重新启动进程(这会丢失高内存使用率的状态),xperf需要安装Windows Performance Toolkit,这可能并不总是一个立即的选项。
当我们有这样一个转储时,我们可能无法明确指出是哪段代码产生了高内存使用率,但我们可以将故障排除的范围缩小到特定的dll。我们需要做的第一件事是确定什么类型的内存使用了大部分地址空间。调试器命令!address -summary允许我们执行以下操作:
0:000> !address -summary
--- Usage Summary ---------------- RgnCount ----------- Total Size -------- %ofBusy %ofTotal
Free 489 7fe`6ff5a000 ( 7.994 Tb) 99.92%
Heap 9094 1`75ed1000 ( 5.843 Gb) 93.47% 0.07%
<unknown> 275 0`12e41000 ( 302.254 Mb) 4.72% 0.00%
Image 937 0`05a6a000 ( 90.414 Mb) 1.41% 0.00%
Stack 138 0`01700000 ( 23.000 Mb) 0.36% 0.00%
Other 14 0`001bd000 ( 1.738 Mb) 0.03% 0.00%
TEB 46 0`0005c000 ( 368.000 kb) 0.01% 0.00%
PEB 1 0`00001000 ( 4.000 kb) 0.00% 0.00%
从这个例子中我们可以看到大部分内存被堆使用。一个进程通常有多个堆,每个堆都是通过调用HeapCreate创建的。我们可以检查这些堆的大小!heap -s:
0:000> !heap -s
LFH Key : 0x0000006c1104d280
Termination on corruption : ENABLED
Heap Flags Reserv Commit Virt Free List UCR Virt Lock Fast
(k) (k) (k) (k) length blocks cont. heap
-------------------------------------------------------------------------------------
0000000000100000 00000002 16384 12824 16384 1180 254 5 0 3 LFH
0000000000010000 00008000 64 4 64 1 1 1 0 0
00000000003d0000 00001002 1088 708 1088 121 20 2 0 0 LFH
0000000003080000 00001002 1536 700 1536 4 4 2 0 0 LFH
00000000033a0000 00001002 5229696 1377584 5229696 414244 4039 3059 0 2c LFH
External fragmentation 30 % (4039 free blocks)
Virtual address fragmentation 73 % (3059 uncommited ranges)
0000000003380000 00001002 64 8 64 3 1 1 0 0
0000000003600000 00001002 512 56 512 3 1 1 0 0
0000000003c20000 00001002 512 8 512 3 1 1 0 0
0000000003220000 00001002 512 8 512 3 1 1 0 0
0000000003e50000 00001002 512 8 512 3 1 1 0 0
0000000003d00000 00001002 512 148 512 5 3 1 0 0 LFH
从上面的输出中我们可以看到大部分内存被堆00000000033a0000占用。在这一点上,我们需要尝试确定这个堆的用途。执行此操作的一种强力方法是使用“s”命令搜索内存。
0:000> s -q 0 l?7fffffffffffffff 00000000033a0000
....
000007fe`f21810a0 00000000`033a0000 00000000`00000001
关于std::__non_rtti_object异常
什么是std::__non_rtti_object异常?
不是一个RTTI对象异常
继承关系:
__non_rtti_object : public bad_typeid
备注
当指针指向的是一个无效的对象,引发此异常。例如,它是一个错误的指针,或者代码不是用/GR编译的