CLR 全面透彻解析
提高应用程序启动性能
Claudio Caldato
由于等待应用程序启动是令许多用户都感到沮丧的一件事情,因此,侧重于提高客户端应用程序的启动性能将极大增强客户的第一印象,并使他们对您的努力成果印象深刻。同时,鉴于启动性能对用户非常重要,所以值得研究一下其影响因素,这样才能避免最常见的错误。
应用程序启动通常分为冷启动和热启动。在托管应用程序环境中,冷启动是指 Microsoft
® .NET Framework 系统程序集和应用程序代码均不在内存中时,因而需要从磁盘提取它们。热启动则是指应用程序的后续启动,或者当大部分系统代码因之前由另一托管应用程序使用而已经存在于内存中时的应用程序启动。
冷启动
在大多数情况下,冷启动受 I/O 限制。换句话说,等待数据花费的时间长于处理指令所用的时间。启动应用程序所用时间等于操作系统从磁盘提取代码所用时间与执行其他处理(如对 IL 代码执行 JIT),以及在应用程序启动路径中执行的任何其他初始化所用时间的总和。由于处理通常并不是冷启动的瓶颈,因此,所有应用程序启动性能调查的初始目标都是通过降低加载的代码量来减少磁盘访问。
写入应用程序代码的方法对冷启动也有着重要影响,所以弄清楚下列情况非常重要,如启动时应用程序是否打开其他文件或者启动可能会争夺 I/O 资源的其他进程。
由于冷启动是受 I/O 限制的情形,因此使用传统的 CPU 分析器(无论是基于检测还是基于采样)对调查并无显著帮助。基于检测的分析器将等待 I/O 所用的时间报告为阻塞时间。问题是,即使您能够将阻塞时间归因于某个特定调用堆栈,阻塞时间也仅计一次。之后的所有磁盘 I/O 都会被忽略,从而导致将磁盘 I/O 的部分实际时间当成了总执行时间。
使用基于采样的分析器时,所收集信息甚至可能会产生误导。它跟踪的是 CPU 使用率而非 I/O,因此,在分析器的报告中不会记录 I/O 所用的总时间。
可通过
图 1 切身体会一下冷启动由于连续两次启动应用程序而受 I/O 限制的情况。第一次启动很可能比第二次启动慢很多(在第二次启动时,由于第一次启动的原因,执行所需的大部分代码均已存在于内存中,因而避免了磁盘访问并节省了时间)。当然,要确保第一次启动是真正的冷启动,首先需要重新启动计算机,并确保在用户登录时启动文件夹中无托管应用程序,并且没有使用托管代码的 Windows
® 服务在运行。
![]()
Figure 1
冷启动中的磁盘读取时间和 CPU 时间
请注意,要执行理想的冷启动测试,应禁用 SuperFetch 服务,否则它可能会预加载一些应用程序所需的代码,从而造成更热的启动情形。在关闭 SuperFetch 的情况下进行测量的好处是:可确定应用程序所需的全部代码均是在应用程序启动时才载入内存,因而可更加准确地衡量 I/O 的成本。然而,应记住的一点是:您所衡量的并不一定就是实际的用户体验,因此切勿根据关闭 SuperFetch 时收集的数据对应用程序的实际性能作出任何具体结论。
可使用以下两个性能计数器来了解冷启动对 I/O 的影响:处理器时间百分比和磁盘读取时间百分比。如果 I/O 制约冷启动(事实应该就是如此),您会发现处理器时间百分比和磁盘读取时间百分比之间存在着很大差异。可使用 PerfMon 来收集性能计数器(有关详细信息,请参阅“启动性能资源”侧栏)。
在
图 1 中,红线代表磁盘读取时间百分比,绿线代表处理器时间百分比。在冷启动的情况下,您会发现与从磁盘读取所用时间相比,CPU 使用率相对较低。
第二次启动应用程序时则为热启动情形,因此性能计数器会显示另一幅图片。
图 2 为受 CPU 限制的情形,如您所见,与处理器时间百分比相比,磁盘读取时间百分比非常低。
![]()
Figure 2
热启动时间更短
热启动受 CPU 限制,因为代码已存在于内存中,所以无需其他 I/O;但在运行应用程序之前需先对代码执行 JIT。现在借助 .NET Framework,JIT 所生成的本机代码不会在每次执行应用程序时都保存。
如果您发现热启动时间并未明显短于冷启动,则需弄清楚是什么正在占用 CPU 周期(因为热启动时已预加载了大部分代码,因此不可能受 I/O 限制)。可能的原因包括必须对大量代码执行 JIT,或者应用程序必须执行非常复杂的计算。
要确定 JIT 执行是否为问题所在,可检查 JIT 中性能计数器 .NET CLR JIT 的时间百分比。如果值不高(例如,对于大部分启动时间,超过 30%-40%),则意味着 JIT 不太可能是主要因素,应使用分析器来确定应用程序中的哪些功能占用了大部分 CPU 时间。请记住,仅当对方法实际执行 JIT 时,计数器才会更新。这意味着在对最后一个方法执行 JIT 后,计数器仍会报告上一个值;它不会降为零。因此,请确保仅在应用程序启动时的前几秒查看该计数器;此时,您可能会发现计数器数值增加的非常快,这表明 CPU 使用率峰值是由 JIT 编译器所致。
另请注意,用户登录时加载的所有应用程序一定会与其他服务和应用程序一起争夺 I/O,从而导致启动时间更长。因此,应尽量避免向启动组添加应用程序(可利用 AutoRuns 这个不错的工具来确定将哪些应用程序设置为在计算机启动时运行,该工具可从
microsoft.com/technet/sysinternals/Security/Autoruns.mspx 处获得)。
确定从磁盘加载的代码
下一步是确定从磁盘加载的内容并弄清楚是否有无意间加载的代码。确定已载入内存的内容的最快捷方法是使用 VADump 工具(可在 Windows Platform SDK 中找到)。
图 3 显示了通过运行以下命令生成报告的摘录:
![]() Category Total Private Shareable Shared Pages KBytes KBytes KBytes KBytes Page Table Pages 177 708 708 0 0 Other System 39 156 156 0 0 Code/StaticData 8169 32676 2160 8336 22180 Heap 14042 56168 56168 0 0 Stack 0 0 0 0 0 Teb 0 0 0 0 0 Mapped Data 8 32 0 4 28 Other Data 1 4 4 0 0 Total Modules 8169 32676 2160 8336 22180 Total Dynamic Data 14051 56204 56172 4 28 Total System 216 864 864 0 0 Grand Total Working Set 22436 89744 59196 8340 22208 Module Working Set Contributions in pages Total Private Shareable Shared Module 72 2 70 0 HeadTrax - HeadTrax.exe 107 7 0 100 ntdll.dll 37 4 6 27 mscoree.dll 77 3 0 74 KERNEL32.dll 6 2 0 4 LPK.DLL 27 4 0 23 USP10.dll 116 4 0 112 comctl32.dll 878 23 79 776 mscorwks.dll Heap Working Set Contributions 0 pages from Process Heap (class 0x00000000) 0 pages from Process Heap (class 0x00000000) 9332 pages from Process Heap (class 0x00000000) 0x0255850F - 0xC255350F 9332 pages 0 pages from Process Heap (class 0x00000000) 0 pages from Process Heap (class 0x00000000) 4710 pages from Process Heap (class 0x00000000) 0x00040000 - 0x10040000 4710 pages 0 pages from Process Heap (class 0x00000000) Stack Working Set Contributions 0 pages from stack for thread 00001018 0 pages from stack for thread 000017EC 0 pages from stack for thread 0000187C VADump –sop <proc ID>
需要记住的重要一点是:VADump 仅显示在运行此工具时加载到内存中的内容,因此,它可能会漏掉仅在内存中加载很短时间的模块。它也不会显示已分页输出到磁盘的应用程序部分(代码或数据)。因此,目标是查看 VADump 报告以确定是否有必要加载列表中的所有模块。例如,如果您的应用程序并未使用 XML 而您发现加载了 System.Xml,则有必要调查一下原因。
可通过在 Windows 调试器 (windbg) 中运行 sxe 命令来确定加载程序集的对象。“sxe ld:<dll name>”命令将导致调试器在加载指定的 DLL 时中断。之后,可检查调用堆栈以确定哪一个功能导致 DLL 被加载到内存中。不应低估此方面的调查。应用程序实际加载到内存中的内容极易被忽视。
系统程序集和其他进程
解决了在启动时加载所有不必要的程序集这一问题后(如果需要进一步改进,还可以修改应用程序代码以延迟启动时执行的某些初始化工作),下一步就是减少从系统程序集加载的代码量。不幸的是,据我所知,如果使用了系统 API,则没有工具可以确定所提取的代码量。如果存在此类工具则会非常有用,因为开发人员可以在启动代码中使用需要从系统程序集加载更少代码的 API。在出现此类工具后,您就可以利用基于检测的分析器(如 Visual Studio
® 性能工具)来了解某个 API 的大致页面成本。
通过查看分析数据,您可以尽量避免涉及那些具有大型调用树(大型树的深嵌套调用意味着每个方法调用的代码均从磁盘提取—因此,这是一种代价高昂的调用)的系统调用的 API。如果可以通过调用无深系统调用树的 API 来实现相同的功能,则会节省时间。这并非是一个科学的方法,因为它很难确定去掉一个调用树可节省多少代码,但通常情况下会具有一定意义,毕竟调用树越大,需要从磁盘加载的代码量就越大。
在某些情况下,您的应用程序可能在启动时显式或隐式启动其他进程。可通过在 Windows 调试器 (windbg) 中使用 –o 选项来轻松地确定它们是什么进程。–o 选项会使调试器附加到任何子进程。应用程序隐式启动进程的典型示例发生在应用程序使用 XML 序列化且未预编译序列化类时(使用 Sgen 实用程序)。此时,将启动 C# 编译器来编译它们。启动其他进程通常是一个代价高昂的操作,会对启动造成重大影响。
NGen 性能
本机映像生成 (NGen) 始终有助于改善热启动,因为它可以避免对代码执行 JIT。如果无需加载 mscorjit.dll,则 NGen 对冷启动情形也会有所帮助,因为应用程序所用的全部代码已使用 NGen 进行了预编译。但是,如果只有一个模块没有对应的本机映像,则仍会加载 mscorjit.dll。然后,不仅会对代码执行 JIT(因而占用 CPU 周期),而且还会由于 JIT 编译器需要读取元数据而接触到 NGen 映像中的许多页面。这将导致启动更糟。为此,建议删除可能导致在启动期间执行 JIT 的所有代码。当然,是否应采用此方法只能在使用和不使用生成的本机映像测量冷启动性能后再做决定,因为 NGen 对冷启动的实际优势取决于应用程序代码和大小,因此即使在启动时并无 JIT 操作,也无法保证会有重大的启动改进。
确定是否以及何时执行 JIT 的一个方法是使用托管调试助理 (MDA)。JIT MDA 允许您在对某方法执行 JIT 时进入调试器或打印调试信息。可通过设置环境变量来启用 MDA,如下所示:
COMPLUS_MDA=JitCompilationStart
当对代码执行 JIT 时,应用程序会进入调试器。也可使用注册表或应用程序的 .config 文件来设置 MDA。有关如何使用 MDA 的详细信息,请参阅“启动性能资源”侧栏。
通常情况下,为保证 NGen 有利于冷启动性能,请确保:
Authenticode 验证
可使用 signcode 工具对程序集执行 authenticode 签名。Authenticode 验证对启动始终具有负面影响,因为 authenticode 签名后的程序集需要由证书颁发机构 (CA) 来验证。此验证需要校验用于签名程序集的 CA,而这一操作由于需要访问网络(如果未将 CA 本地安装在同一计算机上)而代价高昂。
理想情况下,应避免对程序集进行 authenticode 签名,请改为使用强名称签名。如果无法避免 authenticode 签名,可在 .NET Framework 3.5 中使用以下配置选项来跳过验证:
<configuration> <runtime> <generatePublisherEvidence enabled="false"/> </runtime> </configuration>
但是,请注意:在必须进行 authenticode 签名时,仍可节省验证所需的大部分时间,方法是在客户端计算机上安装 CA 证书。
结束语
为获得良好的冷启动性能,最佳做法是让启动时执行的代码极其精简。这意味着延迟并非必不可少的初始化、检查所有引用以确保其加载速度不是过快,并尽量使用无需加载大量代码的类和方法。请记住,目标是减少磁盘访问。这并非易事,但即将发布的 Windows Server
® 2008 SDK 中包含一个非常有用的新工具 Xperf,它使用 Windows 事件跟踪 (ETW) 来跟踪加载的模块、上下文切换和其他事件,从而有助于确定应用程序启动期间所发生的操作。使用 Xperf,您将可以收集有关应用程序启动时间的非常精确的指标。“启动性能资源”侧栏中包含许多非常有用的详细参考。
请将您想询问的问题和提出的意见发送至 clrinout@microsoft.com. |
提高应用程序启动性能
最新推荐文章于 2024-09-04 20:09:44 发布