CLR 全面透析

CLR 4 中的生产诊断改进

Jon Langdon

在公共语言运行库 (CLR) 团队,我们有个小组专门提供 API 和服务,供其他人构建托管代码的诊断工具。我们所拥有(就专用的工程资源而言)的两个最大组件是托管调试和分析 API(分别是 ICorDebug* 和 ICorProfiler*)。

与其他 CLR 和框架团队类似,我们也只能通过努力开发应用程序实现我们的价值。例如,Visual Studio 团队会将这些调试和分析 API 用于他们的托管调试器和性能分析工具,还有大量第三方开发人员在使用分析 API 构建工具。

在过去 10 年中,这个领域主要关注(无论是对于 CLR 还是 Visual Studio)在开发人员的桌面系统上实现功能:在调试器中逐步运行源代码以找到代码中的错误;在性能探查器下启动应用程序来找出速度低的代码路径;使用“编辑并继续”功能减少“编辑-构建-调试”周期所花时间等等。这些工具有助于在应用程序已经安装到用户的计算机或部署到服务器后(这两种情况以下将统称生产)查找其中的错误,我们有许多第三方供应商会以我们的工作为基础构建世界一流的生产诊断工具。

不过,客户和这些供应商也一直在向我们提供反馈,强调进一步简化应用程序整个生命周期的错误查找过程的重要性。毕竟,在应用程序生命周期中发现软件错误的时间越晚,修复代价就越高昂,这一点已成共识。

针对这类反馈意见,我们付出了大量努力,并开始将我们的诊断 API 支持的范畴向这一频谱的生产端加以扩展,CLR 4(支撑 Microsoft .NET Framework 4 的运行库)正是我们的工作成果的第一次发布。

在本文中,我将讨论我们目前认为特别棘手的一些情况、我们正在采取的解决方式以及它们提供的工具类型。具体而言,我将阐述我们如何发展了调试 API,从而对应用程序崩溃以及挂起情况进行转储调试;我还要阐述我们如何简化了对多线程问题导致的挂起进行检测的过程。

我还将说明,对已经运行的应用程序附加分析工具这项功能可如何进一步简化对这类情况进行故障排除的过程,并大大减少内存消耗过量所引发问题所需的诊断时间。

最后,我将简要说明我们如何通过消除对注册表的依赖而简化了对分析工具的部署过程。整篇文章重点都在介绍作为我们工作成果的新工具类型,不过,为了帮助您了解可如何利用我们通过 Visual Studio 发布的工作成果,我也根据情况讨论了其他一些资源。

转储调试

托管转储调试是我们将随 Visual Studio 2010 推出的一项常用功能。流程转储(一般简称为转储)通常用于对本机代码和托管代码进行生产调试。从本质上讲,转储就是特定时间点进程状态的快照。具体来说,它就是转储到文件中的进程虚拟内存(或其中的某些子集)的内容。

在 Visual Studio 2010 之前,为了调试转储中的托管代码,需要使用专门的 Windows 调试器扩展程序 sos.dll,对转储内容进行分析,而无法使用 Visual Studio 等更熟悉的工具(在产品开发过程中,您可能会在其中编写和调试您的代码)。当您在 Visual Studio 中使用转储诊断问题时,我们希望您能获得类似于停止状态实时调试的高层次体验,也就是说,类似于您对代码进行调试,并在断点处停止。

通常会在应用程序中发生未处理异常(崩溃)时收集转储。您使用转储找出崩溃发生的原因,一般情况下需要先着手查看出错线程的调用堆栈。发生应用程序挂起和内存使用问题时也会用到转储。

例如,如果您的网站已停止处理请求,您可以附加调试器、收集转储,然后重新启动应用程序。例如,对转储进行离线分析可能表明,您所有处理请求的线程都在等待连接到数据库;或者,您可能会在代码中发现死锁。从最终用户的角度来看,内存使用问题可有多种表现形式:因过量收集垃圾而导致应用程序速度变慢;因应用程序耗尽虚拟内存而导致服务中断,需要重新启动,等等。

调试 API 通过 CLR 2 仅支持调试正在运行的进程,这使得工具很难定位我们刚刚讲述的情景。从本质上讲,API 在设计时就没有考虑转储调试这类情况。API 使用运行于目标进程中的帮助器线程处理调试器请求,这一事实突出说明了这一点。

例如,在 CLR 2 中,如果托管调试器要遍历某线程的堆栈,它会向所调试进程中的帮助器线程发送请求。此进程中的 CLR 会处理请求,并将结果返回调试器。由于转储只是一个文件,这种情况下没有帮助器线程来处理请求。

为了解决调试转储文件中的托管代码的问题,我们需要构建不需要在目标中运行代码即可检查托管代码状态的 API。然而,为提供实时调试,调试器编写器(主要是 Visual Studio)已经在 CLR 调试 API 方面进行了大量投入,我们不希望强制使用两种不同的 API。

我们在 CLR 4 中重新实现了许多调试器 API(主要是代码和数据检查所需的 API),以避免再使用帮助器线程。其结果就是,现有 API 不再需要考虑目标是转储文件还是有效进程。此外,调试器编写器能够使用同一 API 定位实时调试和转储调试两类情况。当执行控制明确是在进行实时调试时(设置断点,逐步执行代码),调试 API 仍会使用帮助器线程。从长远来说,我们还是倾向于消除这类情形下的依赖性。Rick Byers(以前的一名调试服务 API 开发人员)有一篇很有用的博客帖子,较为详细地介绍了这方面的工作,帖子地址是 blogs.msdn.com/rmbyers/archive/2008/10/27/icordebug-re-architecture-in-clr-4-0.aspx

您现在可以使用 ICorDebug 检查转储文件中的托管代码和数据:遍历堆栈、枚举局部变量、获取异常类型等。对于崩溃和挂起,往往能从线程堆栈和辅助数据中找到足够的上下文,以便查找问题的原因。

尽管我们知道内存诊断和其他方案也很重要,只是在 CLR 4 时间表中,我们根本没有足够的时间来构建新的 API,让调试器有能力以这一情形所要求的方式检查托管堆。展望未来,随着我们不断扩大我们所支持的生产诊断范畴,我希望我们能增加此类内容。在本文后面的部分,我将讨论我们所进行的其他有助于解决此类问题的工作。

我还想明确指出,这项工作同时支持 32 位和 64 位目标,同时支持仅托管调试和混合模式(本机和托管)调试。Visual Studio 2010 为包含托管代码的转储提供了混合模式。

监控器锁检查

多线程编程可能会遇到很多困难。无论您是显式编写多线程代码,还是利用框架或库为您完成这项工作,诊断异步和并行代码中的问题都相当具有挑战性。如果您的逻辑工作单元是在单线程上执行,理解因果关系要简单得多,通常只要查看线程的调用堆栈即可确定。但是,当这一工作被划分到多个线程中,跟踪流向就要困难得多:为什么这项工作没有完成?是它的某一部分在某个地方被阻止了?

随着多核技术的应用越来越普遍,开发人员越来越多地依靠并行编程提高性能,而不再完全依靠芯片速度的进步。Microsoft 开发人员也不例外,因此,在过去的几年里,我们一直在集中精力,帮助开发人员在这一领域取得成功。从诊断的角度出发,我们增加了几个简单却很实用的 API,可以让工具帮助开发人员更好地应对复杂的多线程代码。 

对于 CLR 调试器 API,我们添加了对监测锁的检查 API。简单地说,监控器提供了一种方式,使程序可以实现多个线程访问共享资源(.NET 代码中的某个对象)的同步。因此,当一个线程已将资源锁定时,另一个线程会等待解锁。当拥有锁的线程释放它时,第一个处于等待状态的线程现在可以获取此资源。

在 .NET Framework 中,监控器是通过 System.Threading.Monitor 命名空间直接公开的;而在 C# 和 Visual Basic 中,更常见的是分别通过锁和 SyncLock 关键字公开。它们还用于实现同步方法、任务并行库(Task Parallel Library,TPL)和其他异步编程模型。新的调试器 API 可让您更好地了解有哪个对象(如果有的话)阻止了给定线程,以及哪个线程拥有给定对象的锁(如果有的话)。利用这些 API,调试器可以帮助开发人员找出死锁,并了解何时多线程竞争资源(锁保护)可能会影响应用程序的性能。

Visual Studio 2010 中的并行调试功能可作为这项工作所推出的工具类型的一个示例。Daniel Moth 和 Stephen Toub 在 2009 年 9 月这期的 MSDN 杂志 (msdn.microsoft.com/magazine/ee410778) 中对这些功能有精彩的概述。

就转储调试工作而言,最令我们兴奋的事情之一是,构建调试目标的抽象视图就意味着增加“监控-锁定-检查”功能等新的检测功能,这项功能为实时调试和转储调试两种情况都提供了价值。我认为“监控-锁定-检查”功能在开发人员最初开发应用程序时对他们非常有价值,对转储调试的支持更是使得它成为 CLR 4 生产诊断功能中增加的一项引人注目的功能。

Microsoft 支持工程师 Tess Ferrandez 在一段 Channel 9 视频 (channel9.msdn.com/posts/Glucose/Hanselminutes-on-9-Debugging-Crash-Dumps-with-Tess-Ferrandez-and-VS2010/) 中模拟了她在对客户应用程序进行故障排除时经常遇到的锁保护情形。然后,她逐步引导用户使用 Visual Studio 2010 来诊断此问题。这一示例很好地说明了这些新功能所实现的应用类型。

超越转储

虽然我们相信这些功能所实现的工具有助于减少开发人员解决生产问题所需的时间, 我们不认为(或期望)转储调试会是诊断生产问题的唯一方式。

在诊断过量使用内存问题时,我们通常首先会按类型对对象实例进行分组,列出各个实例及其计数、聚合规模和进展,从而理解对象的引用链。在生产用计算机、开发用计算机、操作人员、技术支持工程师和开发人员之间来回传递包含这一信息的转储文件,这类过程可能非常耗时、费力。随着应用程序的规模增长(对于 64 位应用程序尤其普遍),转储文件的规模也会增长,传递和处理都需要更长的时间。正是考虑到这些高层次应用,我们承担了在以下部分中描述的分析功能的开发。

分析工具

在 CLR 分析 API 的基础上构建了多种不同类型的工具。涉及分析 API 的情况一般都集中关注三个功能类别:性能、内存和工具化。

性能探查器(类似于某些 Visual Studio 版本中的探查器)集中关注代码如何占用时间。内存探查器详细说明应用程序的内存使用情况。工具化探查器负责其余所有部分。

让我们简单说明一下最后一句。分析 API 提供的功能之一是在运行时将中间语言 (IL) 插入托管代码。我们称之为代码工具化。 客户可使用此功能来构建工具,针对基于 .NET Framework 的应用程序实现从代码覆盖到错误注入到企业级生产监控等一系列应用场景。

相对于调试 API,分析 API 的优势之一是,它有相当轻量级的设计。两者都是事件驱动的 API,例如,在这两个 API 中都有程序集加载、线程创建、异常引发等事件;但就分析 API 而言,您可以只注册自己关心的事件。此外,分析 DLL 会加载到目标进程中,以确保快速访问运行时状态。

与此相反,调试器 API 会将每一个事件报告给进程外调试器(如有附加的话),并在发生每一个事件时都挂起运行库。在针对生产环境构建在线诊断工具方面,分析 API 是一个很有吸引力的选择,而其原因还有很多,这些仅仅是其中的几个。

探查器附加和分离

尽管一些供应商正在通过 IL 工具化构建永不间断的“生产-应用-监控”工具,我们目前没有很多的工具可利用分析 API 中的性能和内存监控功能,以支持被动诊断。一直以来,这种情况下的主要障碍都是无法将基于 CLR 分析 API 的工具附加到已经运行的进程。

在 CLR 4 之前的版本中,CLR 都会在启动过程中检查是否有探查器注册。如果发现有注册的探查器,CLR 会进行加载并根据请求提供回调。DLL 永远不会卸载。如果该工具的目的是从端到端的角度了解应用程序的行为,这种方式的效果通常还不错;不过,对于应用程序启动时已经存在但您没有觉察的问题,它是无效的。

也许内存使用诊断是这方面最令人棘手的一个例子。今天,在这种情况下,我们常常会看到以下诊断模式:收集多个转储,查看相互之间所分配类型之间的差异,构建增长时间线,然后在应用程序代码中找到引用可疑类型的地方。也许问题出在缓存方案实施效果不良,或某一类型的事件处理程序可能持有了对另一类型的引用,否则将超出范围。客户和支持工程师会花很多时间诊断这类问题。

首先,我在上面简要地说明过,规模较大的进程的转储本身也较大,将它们拿到专家处进行诊断,可能需要延误很久才能解决。此外,主要归因于只能通过 Windows 调试器扩展程序公开数据,您将不得不求助于专家解决此类问题。没有公共 API 实现工具对数据的使用,或在此基础上构建直观的视图,或将其与其他工具相集成,为分析提供帮助。

为有助于解决这种情况下(和一些其他情况下)的问题,我们增加了新的 API,允许探查器附加到正在运行的进程,并充分利用现有分析 API 的子集。附加到进程后,可以实现的功能有采样(请参阅“VS 2010:将探查器附加到托管应用程序”,网址为 blogs.msdn.com/profiler/archive/2009/12/07/vs2010-attaching-the-profiler-to-a-managed-application.aspx)和内存诊断:遍历堆栈;将函数地址映射到符号名称;大多数的垃圾回收器 (GC) 回调;对象检查。 

在我刚才所描述的情形中,工具可以利用此功能,让客户附加到一个响应时间变长或内存增长过量的应用程序,了解当前正在执行哪些功能,托管堆上有哪些有效类型,以及什么原因使它们保持有效状态。收集此信息后,您可以分离工具,而 CLR 将卸载探查器 DLL。尽管工作流的其余部分与此类似(即查找代码中引用或创建这些类型的位置),我们还是认为这种性质的工具能对这些问题的平均解决时间产生相当大的影响。作为 CLR 分析 API 开发人员的 Dave Broman 在他的博客 (blogs.msdn.com/davbr) 上更为深入地讨论了这个问题和其他分析功能。

不过,我希望在这篇文章中明确指出两项限制。首先,附加式内存诊断仅限于非并发式(或拦截式)GC 模式:执行某个 GC 时,GC 会挂起对所有托管代码的执行。虽然默认情况下会采用并发 GC,ASP.NET 使用的却是非并发模式的服务器模式 GC。在这一环境中,我们看到了这些问题中的大部分问题。我们欣赏“附加-分析”诊断对客户端应用程序的作用,希望在未来版本中推出这一功能。我们只是优先解决了 CLR 4 的更常见问题。

其次,附加后您无法使用分析 API 来实现 IL 的工具化。如果我们的企业监控工具供应商希望根据运行时状况动态改变工具化功能,此功能尤其重要。我们通常将此功能称为 re-JIT。目前,当方法的 IL 正文第一次进行 JIT 编译时,您有机会对其加以改变。我们将推出 re-JIT 看作是一项有重大意义的任务,正积极调查这项功能所能产生的客户价值以及对技术的影响力,从而考虑在未来版本中推出这项工作成果。

探查器的免注册表激活

探查器附加以及附加后对启用具体分析 API 的支持工作是我们在 CLR 4 的 CLR 分析 API 中完成的最大一项工作。不过,看到另一项非常小(就工程成本而言)的功能能对客户构建生产级工具产生令人惊讶的巨大影响,我们也很高兴。

在 CLR 4 之前,工具为获取在托管应用程序中加载的探查器 DLL,必须创建两方面的关键配置信息:首先是一对环境变量,指示 CLR 启动分析功能以及在 CLR 启动时加载何种探查器实现方式(它的 CLSID 或 ProgID)。鉴于探查器 DLL 是作为进程内 COM 服务器实现的(CLR 是客户端),配置数据的第二部分是存储在注册表中的相应 COM 注册信息。这主要是通过 COM 告知运行库在磁盘的何处可以找到 DLL。

在 CLR 4 规划过程中,我们尽力了解我们可如何简化供应商构建生产级工具的过程,在此期间,我们同一些支持工程师和工具供应商进行了几次交谈,内容很有趣。我们收到的反馈是,当在生产中面临应用程序失败时,客户通常较少关心对应用程序(它已经失败了;他们只想获得诊断信息,使操作继续进行)的影响,更多地是关心对机器状态的影响。许多客户会不遗余力地记录并检查他们的机器配置,而对该配置加以更改可能引入某些风险。

因此,就解决方案的 CLR 部分而言,我们希望可以启用通过 XCopy 进行部署的诊断工具,如此既最大限度地减少了状态变化的风险,同时又缩短了工具在机器上启动并运行所需的时间。

我们对 CLR 4 的设置是,不需要再在注册表中注册 COM 探查器 DLL。如果您要在探查器下启动您的应用程序,我们仍然需要环境变量;但对于上文详述的附加情况没有配置要求。工具只是调用新的附加 API,将路径传递给 DLL,CLR 即会加载探查器。因为某些情况下客户仍会发现环境变量解决方案很棘手,未来我们要研究如何进一步简化探查器的配置。

将我刚才讨论的两项分析功能相结合,可以推出一类低开销工具。在突发故障时,客户可以将这类工具迅速部署到生产机器,以收集性能或内存诊断信息,帮助查明问题的原因。然后用户就可以尽快使机器恢复到原始状态。

总结

为 CLR 开发诊断功能的工作苦乐参半。我目睹过多次客户如何努力解决某些问题,深感欣慰的是:我们总能完成够酷的工作,可以简化开发过程,让客户更轻松愉快。如果我们客户感到棘手的问题列表没有改变的话(也就是说,我们没有从中删除项目),那么,我们就不算成功。

我认为我们在 CLR 4 中所构建的功能不仅为解决客户所面临的一些最紧迫的问题提供了立竿见影的价值,而且还为我们未来继续推出其他功能奠定了基础。展望未来,我们将继续在 API 和服务上加大投入,以便在应用程序生命周期的各个阶段提供有意义的诊断信息,让您可以集中精力为您的客户构建新功能,而减少调试现有应用程序所花的时间。

如果您有兴趣对我们要在下一个版本考虑的诊断工作提供反馈,请参与我们在 surveymonkey.com/s/developer-productivity-survey 发布的一项调查。由于我们现在要推出 CLR 4,要花费大量时间规划即将发布的新版本,调查所得数据可以帮助我们区分工作的轻重缓急。如果您能花几分钟写下您的建议,我们不胜感激!                                

Jon Langdon* 是 CLR 团队一名重点关注诊断的程序经理。在加入 CLR 团队之前,他是一名 Microsoft 服务顾问,帮助客户诊断并修复大规模企业应用程序的问题。*

衷心感谢以下技术专家审阅本文:Rick Byers