高性能托管应用程序设计入门
【IT168技术文档】
高性能托管应用程序设计入门(一)
摘要
从性能的角度来学习 .NET Framework 公共语言运行库。学习如何找出托管代码性能的非常好的方法,以及如何测量托管应用程序的性能。
微软上下载 CLR Profiler。(330KB)
软件开发和玩杂耍
我们可以将软件开发过程比作玩杂耍。通常,人们在玩杂耍时至少需要三件物品,而对于可以使用的物品数量并没有任何限制。刚开始学习如何玩杂耍时,您会发现在接球和抛球时您看到的是单个的球。随着熟练程度的增加,您会开始将注意力集中在这些球的运动上,而不是集中在每个单个的球上。当您掌握了玩杂耍的方法时,您就会再次将注意力集中在单个球上,一边努力使这个球保持平衡,一边继续抛出其他的球。凭直觉您就可以知道这些球的运动方向,并且也总是能将手放在正确的地方接球和抛球。可是,这与软件开发又有什么类似之处呢?
不同的角色在软件开发的过程中会“耍弄”不同的三件物品:项目和程序管理人员耍弄的是功能、资源和时间,而软件开发人员则耍弄正确性、性能和安全性。人们在玩杂耍时总是试图使用更多的物品,但只要是学过杂耍的人都会知道,即使只添加一个球都会让在空中保持所有球的平衡变得更加困难。从技术上讲,如果耍弄的球少于三个,那根本就不叫杂耍。作为软件开发人员,如果您不考虑正在编写的代码的正确性、性能和安全性,则说明您根本就没有投入到您的工作中。当您刚刚开始考虑正确性、性能和安全性时,您会发现您每次只能将注意力集中在一个方面。当它们成为您日常工作的一部分时,您会发现您再也不需要将注意力集中在某一个特定的方面,因为此时它们早已融入了您的工作。而一旦掌握了它们,您就能够凭直觉进行权衡,并相应地调整自己的注意点。这就跟玩杂耍一样,关键在于实践。
编写高性能的代码本身也存在三件物品:设置目标、测量和了解目标平台。如果您不知道代码必须达到的运行速度,那么您又如何知道您完成了呢?如果您不测量和分析代码,那么您又如何知道您已经达到了目标,或为什么没有实现目标呢?如果您不了解目标平台,那么当您没有实现目标时,又如何知道要对什么进行优化呢?这些原则对所有的高性能代码开发过程几乎都适用,不管您以哪种平台作为目标平台都一样。要完成一篇有关编写高性能代码的文章,就必须提到以上这三个方面。虽然这三个方面都同等重要,但是本文的重点在于后两个方面,因为它们适用于编写以 Microsoft? .NET Framework 作为目标平台的高性能应用程序。
在任意平台上编写高性能代码的基本原则为:
1、设置性能目标
2、测量,测量,再测量
3、了解应用程序的目标硬件和软件平台
.NET 公共语言运行库
.NET Framework 的核心是公共语言运行库 (CLR)。CLR 为您的代码提供所有的运行时服务:实时[Denver1]编译、内存管理、安全性和大量其他服务。CLR 的设计考虑了高性能的要求。也就是说,您既可以充分利用其性能,也可以不发挥这些性能。
本文将从性能的角度概要介绍公共语言运行库,找出托管代码性能的非常好的办法,还将展示如何测量托管应用程序的性能。本文并不打算对 .NET Framework 的性能特点进行全面讨论。根据本文的目的,文中提到的性能方面的内容将会包括吞吐量、可扩展性、启动时间和内存使用量等。
托管数据和垃圾回收器
在以性能为中心的应用程序中使用托管代码时,开发人员最关心的问题之一就是 CLR 内存管理的开销 - 这种管理由垃圾回收器 (GC) 执行。内存管理的开销由与类型实例关联的内存的分配开销、在实例的整个生命周期中内存的管理开销以及当不再需要时内存的释放开销计算得出。
托管分配的开销通常都非常小,在大多数情况下,比 C/C++ malloc 或 new 所需的时间还要少。这是因为 CLR 不需要通过扫描可用列表来查找下一个足以容纳新对象的可用连续内存块,而是一直都将指针保持指向内存中的下一个可用位置。我们可以将对托管堆的分配看作“类似于栈”。如果 GC 需要释放内存才能分配新对象,那么分配可能会引发回收操作。在这种情况下,分配的开销就会比 malloc 和 new 大。固定的对象也会对分配的开销造成影响。固定的对象是指那些 GC 接到指令在回收操作期间不能移动其位置的对象,通常是由于这些对象的地址已传递到本机 API 中。
与 malloc 或 new 不同的是,在对象的整个生命周期中管理内存都会带来开销。CLR GC 区分不同的代,这就意味着它不是每次都回收整个堆。但是,GC 仍然需要了解剩余堆中的活动对象的根是否是堆中正在回收的那部分对象。如果内存中包含的对象具有对其下一代对象的引用,那么在这些对象的生命周期中,管理内存的开销将会非常大。
GC 是代标记和空闲内存的回收器。托管堆包含三代:第 0 代包含所有的新对象,第 1 代包含存活期较长的对象,第 2 代包含长期存活的对象。在释放足够的内存以维持应用程序继续运行的前提下,GC 将尽可能从堆中回收最小的部分。代的回收操作包括对所有下一代的回收,在这种情况下,第 1 代回收也会回收第 0 代。第 0 代的大小会根据处理器缓存的大小和应用程序的分配率动态变化,它用于回收的时间通常都不会超过 10 毫秒。第 1 代的大小根据应用程序的分配率动态变化,它用于回收的时间通常介于 10 到 30 毫秒之间。第 2 代的大小取决于应用程序的分配配置文件,它用于回收的时间也取决于此文件。对管理应用程序内存的性能开销影响最大的正是这些第 2 代的回收操作。
提示:GC 具备自我调节能力,它会根据应用程序内存的要求对自身进行调节。多数情况下,通过编程方式调用 GC 时,它的调节功能都未启用。通过调用 GC.Collect 来“帮助”GC 通常无法提高您的应用程序的性能。
GC 可以在回收对象期间重定位存活的对象。如果这些对象比较大,那么重定位的开销也会较大,因此这些对象都将分配到堆中的一个称为大对象堆 (Large Object Heap) 的特殊区域中。大对象堆可以被回收,但不能压缩,例如,这些大对象不能进行重定位。大对象是指那些大于 80k 的对象。请注意,在将来发行的 CLR 版本中此概念可能会有所变化。需要回收大对象堆时,将强制进行完全回收,而且是在回收第 2 代时才回收它们。大对象堆中对象的分配率和死亡率都会对管理应用程序内存的性能开销产生很大的影响。
分配配置文件
托管应用程序的全局分配配置文件定义了垃圾回收器对与应用程序相关的内存进行管理的工作量有多大。GC 管理内存的工作量越大,GC 所经历的 CPU 周期数就越多,而 CPU 运行应用程序代码所花费的时间也就越短。分配配置文件由已分配对象数、对象的大小及其生命周期计算得出。缓解 GC 压力的一种最明显的方法就是减少分配的对象数量。使用面向对象设计技术将应用程序设计为具有可扩展性、模块化和可复用的特性,往往会导致分配的数量增多。抽象和“精确”都会导致性能下降。
GC 友好的分配配置文件中将包含一些在开始执行应用程序时分配的对象,这些对象的生命周期与应用程序一致,而其他对象的生命周期都比较短。存活时间较长的对象很少或不包含对存活时间较短的对象的引用。当分配配置文件偏离此原则时,GC 就必须努力工作以管理应用程序的内存。
GC 不友好的分配配置文件中将包含一些可以存活到第 2 代但随后就会死去的对象,或者将包含一些要分配到大对象堆的存活时间较短的对象。那些存活时间长得足以进入第 2 代而随后就会死去的对象,是管理开销最大的对象。就像我在前面提到的,如果在执行 GC 期间上一代中的对象包含对新一代对象的引用,也会增加回收的开销。
典型的实际分配配置文件介于上面提到的两种分配配置文件之间。分配配置文件的一个重要度量标准是 CPU 花在 GC 上的时间占其总时间的百分比。您可以通过 .NET CLR Memory:% Time in GC 性能计数器获得这一数字。如果此计数器的平均值大于 30%,则您可能需要对您的分配配置文件进行一次仔细的检查。这并不一定意味着您的分配配置文件有问题,在某些占用大量内存的应用程序中,GC 达到这种水平是必然的,也是正常的。当您遇到性能问题时,首先应该检查此计数器,它将立即显示出您的分配配置文件是否出现了问题。
提示:如果 .NET CLR Memory:% Time in GC 性能计数器指示您的应用程序花在 GC 上的平均时间高于它的总时间的 30%,则表明您需要对您的分配配置文件进行一次仔细的检查。
提示:GC 友好的应用程序中包含的第 0 代对象远远多于第 2 代对象。此比率可以通过比较.NET CLR Memory:# Gen 0 Collections 和.NET CLR Memory:# Gen 2 Collections 性能计数器的结果来得出。