UML软件工程组织

编写高性能的托管应用程序:入门
Gregor Noriskin
Microsoft CLR Performance Team
适用于:
Microsoft® .NET Framework

摘要:从性能的角度来学习 .NET Framework 公共语言运行库。学习如何找出托管代码性能的最佳方法,以及如何测量托管应用程序的性能。

下载 CLR Profiler。(330KB)

目录

软件开发和玩杂耍
.NET 公共语言运行库
托管数据和垃圾回收器
分配配置文件
用于分析的 API 和 CLR 分析器
集成服务器 GC
终结 (Finalization)
清理模式 (Dispose Pattern)
弱引用注释
托管代码和 CLR JIT
值类型
异常处理
线程和同步
反射
后期绑定
安全性
COM 互操作和平台调用
性能计数器
其他工具
小结
资源

软件开发和玩杂耍

我们可以将软件开发过程比作玩杂耍。通常,人们在玩杂耍时至少需要三件物品,而对于可以使用的物品数量并没有任何限制。刚开始学习如何玩杂耍时,您会发现在接球和抛球时您看到的是单个的球。随着熟练程度的增加,您会开始将注意力集中在这些球的运动上,而不是集中在每个单个的球上。当您掌握了玩杂耍的方法时,您就会再次将注意力集中在单个球上,一边努力使这个球保持平衡,一边继续抛出其他的球。凭直觉您就可以知道这些球的运动方向,并且也总是能将手放在正确的地方接球和抛球。可是,这与软件开发又有什么类似之处呢?

不同的角色在软件开发的过程中会“耍弄”不同的三件物品:项目和程序管理人员耍弄的是功能、资源和时间,而软件开发人员则耍弄正确性、性能和安全性。人们在玩杂耍时总是试图使用更多的物品,但只要是学过杂耍的人都会知道,即使只添加一个球都会让在空中保持所有球的平衡变得更加困难。从技术上讲,如果耍弄的球少于三个,那根本就不叫杂耍。作为软件开发人员,如果您不考虑正在编写的代码的正确性、性能和安全性,则说明您根本就没有投入到您的工作中。当您刚刚开始考虑正确性、性能和安全性时,您会发现您每次只能将注意力集中在一个方面。当它们成为您日常工作的一部分时,您会发现您再也不需要将注意力集中在某一个特定的方面,因为此时它们早已融入了您的工作。而一旦掌握了它们,您就能够凭直觉进行权衡,并相应地调整自己的注意点。这就跟玩杂耍一样,关键在于实践。

编写高性能的代码本身也存在三件物品:设置目标、测量和了解目标平台。如果您不知道代码必须达到的运行速度,那么您又如何知道您完成了呢?如果您不测量和分析代码,那么您又如何知道您已经达到了目标,或为什么没有实现目标呢?如果您不了解目标平台,那么当您没有实现目标时,又如何知道要对什么进行优化呢?这些原则对所有的高性能代码开发过程几乎都适用,不管您以哪种平台作为目标平台都一样。要完成一篇有关编写高性能代码的文章,就必须提到以上这三个方面。虽然这三个方面都同等重要,但是本文的重点在于后两个方面,因为它们适用于编写以 Microsoft® .NET Framework 作为目标平台的高性能应用程序。

在任意平台上编写高性能代码的基本原则为:

  1. 设置性能目标
  2. 测量,测量,再测量
  3. 了解应用程序的目标硬件和软件平台

.NET 公共语言运行库

.NET Framework 的核心是公共语言运行库 (CLR)。CLR 为您的代码提供所有的运行时服务:实时[Denver1]编译、内存管理、安全性和大量其他服务。CLR 的设计考虑了高性能的要求。也就是说,您既可以充分利用其性能,也可以不发挥这些性能。

本文将从性能的角度概要介绍公共语言运行库,找出托管代码性能的最佳办法,还将展示如何测量托管应用程序的性能。本文并不打算对 .NET Framework 的性能特点进行全面讨论。根据本文的目的,文中提到的性能方面的内容将会包括吞吐量、可扩展性、启动时间和内存使用量等。

托管数据和垃圾回收器

在以性能为中心的应用程序中使用托管代码时,开发人员最关心的问题之一就是 CLR 内存管理的开销 - 这种管理由垃圾回收器 (GC) 执行。内存管理的开销由与类型实例关联的内存的分配开销、在实例的整个生命周期中内存的管理开销以及当不再需要时内存的释放开销计算得出。

托管分配的开销通常都非常小,在大多数情况下,比 C/C++ mallocnew 所需的时间还要少。这是因为 CLR 不需要通过扫描可用列表来查找下一个足以容纳新对象的可用连续内存块,而是一直都将指针保持指向内存中的下一个可用位置。我们可以将对托管堆的分配看作“类似于栈”。如果 GC 需要释放内存才能分配新对象,那么分配可能会引发回收操作。在这种情况下,分配的开销就会比 malloc new 大。固定的对象也会对分配的开销造成影响。固定的对象是指那些 GC 接到指令在回收操作期间不能移动其位置的对象,通常是由于这些对象的地址已传递到本机 API 中。

mallocnew 不同的是,在对象的整个生命周期中管理内存都会带来开销。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 性能计数器的结果来得出。

用于分析的 API 和 CLR 分析器

CLR 中包含一个功能强大的用于分析的 API,第三方可用它来为托管应用程序编写自定义分析器。CLR 分析器是一种分配分析示例工具,由 CLR Product Team 编写,但不提供技术支持,该分析器使用的就是这种用于分析的 API。开发人员可以使用 CLR 分析器查看其管理应用程序的分配配置文件。

图 1:CLR 分析器主窗口

CLR 分析器包括大量非常有用的分配配置文件视图,包括:已分配类型的柱状图、分配和调用图表、显示不同代的 GC 和进行这些回收之后托管堆的结果状态的时间线,以及显示各个方法分配和程序集加载情况的调用树。

图 2:CLR 分析器分配图表

提示:有关如何使用 CLR 分析器的详细信息,请参阅 Zip 文件中包含的自述文件。

请注意,CLR 分析器具有高性能的特点,可以显著地改变应用程序的性能特点。如果您在运行应用程序时也运行了 CLR 分析器,因压力而产生的问题很可能都会消失。

集成服务器 GC

有两种不同的垃圾回收器可供 CLR 使用:工作站 GC 和服务器 GC。控制台和 Windows 窗体应用程序中集成了工作站 GC,而 ASP.NET 中集成了服务器 GC 。服务器 GC 针对吞吐量和多处理器的可扩展性进行了优化。服务器 GC 在整个回收期间(包括标记阶段和清除阶段)会暂停所有运行托管代码的线程,并且 GC 会在可供高优先级的 CPU 专用线程中的进程使用的所有 CPU 上并行执行。如果线程在执行 GC 期间运行了本机代码,那么只有当本机调用返回时这些线程才会暂停。如果您要构建的服务器应用程序将要在多处理器计算机上运行,强烈建议您使用服务器 GC。如果您的应用程序不是由 ASP.NET 提供的,那么您就必须编写一个本机应用程序来显式集成 CLR。

提示:如果要构建可扩展的服务器应用程序,请集成服务器 GC。请参阅 Implement a Custom Common Language Runtime Host for Your Managed App(英文)。

工作站 GC 经过优化,其滞后时间非常短,非常适合客户端应用程序。没有人会希望客户端应用程序在执行 GC 期间出现明显的停顿,这是因为客户端的性能通常都不是通过原始吞吐量,而是通过反应性能来测量的。工作站 GC 是并发 GC,这意味着它会在托管代码运行的同时执行标记阶段。工作站 GC 仅在需要执行清除阶段时才会暂停运行托管代码的线程。在工作站 GC 中,由于 GC 仅在一个线程上运行,因而它只在一个 CPU 上运行。

终结 (Finalization)

CLR 提供一种在释放与类型实例关联的内存之前自动进行清除的机制。这一机制称为终结 (Finalization)。通常,终结用于释放本机资源,在这种情况下,则释放由对象使用的数据库连接或操作系统句柄。

终结是一个开销很大的功能,而且它还会加大 GC 的压力。GC 会跟踪 Finalizable 队列中需要执行终结操作的对象。如果在回收期间,GC 发现了一个不再存活且需要终结的对象,它就会将该对象在 Finalizable 队列中的条目移至 FReachable 队列中。终结操作在一个称为终结器线程 (Finalizer Thread) 的独立线程中执行。因为在终结器的执行过程中,可能需要用到该对象的所有状态,因此该对象或其指向的所有对象都会升级至下一代。与对象或对象图相关的内存仅在执行后续的 GC 时才会释放。

需要释放的资源应该包装在一个尽可能小的可终结对象中,例如,如果您的类需要引用托管资源和非托管资源,那么您应该在新的可终结类中包装非托管资源,并使该类成为您的类的成员。父类不能是可终结类。这意味着只有包含非托管资源的类会被升级(假如您没有在包含非托管资源的类中引用父类)。另外还要记住只有一个终结线程。如果有终结器导致此线程被阻塞,就不能调用后续的终结器,也不能释放资源,而且您的应用程序会导致资源泄漏。

提示:应该尽可能使终结器保持简单,且永远不会阻塞。
提示:仅将需要清除的非托管对象的包装类设为可终结。

可以将终结认为是引用计数的一种替换形式。执行引用计数的对象将跟踪有多少其他对象对其进行引用(这会导致一些非常有名的问题),以便在引用计数为零时释放其资源。CLR 没有实现引用计数,因此它需要提供一种机制,以便在不存在对象的引用时自动释放资源。终结就是这种机制。通常,终结仅需要在要清除的对象的生命周期不明确的情况下执行。

清理模式 (Dispose Pattern)

当对象的生命周期不明确时,应该尽快释放与该对象关联的非托管资源。这一过程称为“清理”对象。清理模式通过 IDisposable 接口实现(尽管您自己实现也很容易)。如果您希望对类应用终结,例如,要使类实例可清理,就需要让对象实现 IDisposable 接口并执行 Dispose 方法。使用 Dispose 方法您可以调用终结器中的同一段清除代码,并通知 GC 不需要通过调用 GC.SuppressFinalization 方法来终结该对象。最好同时使用 Dispose 方法和终结器来调用通用终结函数,这样就只需要维护一份清除代码。而且,如果对象的语义为:Close 方法比 Dispose 方法更符合逻辑,那么还应实现 Close 方法,在这种情况下,数据库连接或套接字逻辑上都被“关闭”。Close 可以只是简单地调用 Dispose 方法。

使用终结器为类提供 Dispose 方法始终是一种很好的做法,因为永远没有人能确切地知道使用类的方法,例如,是否可以明确知道它的生命周期。如果您使用的类实现了清理模式,而且您也确切地知道何时清理好对象,最好明确地调用 Dispose

提示:请为所有可终结的类提供 Dispose 方法。
提示:请将终结操作隐藏在 Dispose 方法中。
提示:请调用通用清除函数。
提示:如果您使用的对象实现了 IDisposable,并且您知道已不再需要该对象,请调用 Dispose 方法。

C# 提供了一种非常方便的自动清理对象的方法。使用关键字 using 来标记代码块,之后,将对大量可清理对象调用 Dispose

C# 的 using 关键字

using(DisposableType T)
{
   //对 T 执行一些操作
}
//自动调用 T.Dispose()

弱引用注释

在堆栈中、寄存器中、其他对象上或某一其他 GC 根对象上,对对象的任何引用都会使得该对象在执行 GC 期间保持存活。一般来说,这是一件好事,因为这通常都表示应用程序不是借助该对象来执行的。然而,有些时候您会需要引用某个对象,但又不想影响其生命周期。在这种情况下,CLR 为实现这一目的而提供了一种称为“弱引用”的机制。任何强引用(例如,以对象为根的引用)都可以转换成弱引用。例如,当您需要创建可以遍历数据结构的外部游标对象,但又不影响该对象的生命周期时,可能需要使用弱引用。又例如,当您需要创建一个存在内存压力时就会刷新的缓存时也可能会需要使用弱引用,例如,发生 GC 时。

在 C# 中创建弱引用

MyRefType mrt = new MyRefType();
//...

//创建弱引用
WeakReference wr = new WeakReference(mrt); 
mrt = null; //对象不再是根对象
//...

//对象是否已回收?
if(!wr.IsAlive)
{
   //获得该对象的强引用
   mrt = wr.Target;
   //对象为根对象而且可以再次使用
}
else
{
   //重新创建该对象
   mrt = new MyRefType();
}

托管代码和 CLR JIT

托管程序集是托管代码的分发单位,它由 Microsoft 中间语言(MSIL 或 IL)构成,适用于所有的处理器。CLR 的实时 (JIT) 功能可将 IL 编译成优化的本机 X86 指令。JIT 是一种执行优化操作的编译器,但是由于编译是在软件运行时进行的,并且仅当第一次调用方法时才会进行,因此进行优化的次数需要与执行编译所花费的时间保持平衡。通常,这对于服务器应用程序并不重要,因为启动时间和响应对于它们来说通常都不构成问题;但对于客户端应用程序来说,却十分重要。请注意,安装时可以通过使用 NGEN.exe 执行编译来加快启动时间。

许多由 JIT 执行的优化操作都没有与其关联的编程模式,例如,您无法对它们进行显式编码,但是也有一些优化操作具有关联的编程模式。下一节将讨论后者中的部分操作。

提示:使用 NGEN.exe 实用程序在安装时编译应用程序,可以加快客户端应用程序的启动时间。

方法内联

所有的方法调用都会带来开销。例如,需要将参数推入栈中或存储在寄存器中,需要执行的方法起头 (prolog) 和结尾 (epilog) 等。只需要将被调用方法的方法主体移入调用方的主体,就可以避免某些方法的调用开销。这一操作称为方法内联。JIT 使用大量的探测方法来确定是否应内联某个方法。下面是其中一些比较重要的探测方法的列表(请注意这并不是一个详尽的列表):

  • IL 超过 32 字节的方法不会内联。
  • 虚函数不会内联。
  • 包含复杂流程控制的函数不会内联。复杂流程控制是除 if/then/else 以外的任意流程控制,在这种情况下,为 switchwhile
  • 包含异常处理块的方法不会内联,但是引发异常的方法可以内联。
  • 如果某个方法的所有定参都为结构,则该方法不会内联。

我会认真考虑一下对这些探测方法进行显式编码的问题,因为在以后的 JIT 版本中它们可能会有所变化。请不要为了确保方法可以内联而放弃方法的正确性。您也许已经注意到了一个有趣的现象,C++ 中的关键字 inline __inline 不能保证编译器将一种方法内联(尽管 __forceinline 可以)。

一般情况下,属性的 Get 和 Set 方法都非常适合内联,因为它们主要用于初始化私有数据成员。

提示:请不要为了试图保证内联而放弃方法的正确性。

去除范围检查

托管代码有许多优点,其中一项是可以自动进行范围检查。每次使用 array[index] 语义访问数组时,JIT 都会进行检查以确保索引在数组范围中。在具有大量迭代和少量由每个迭代执行的指令的循环环境中,范围检查的开销可能会很大。在某些情况下,JIT 也可能会在检测到这些范围检查不必要时将其从循环体中删除,即仅在循环开始执行之前对其进行一次检查。在 C# 中有一种编程模式,用来确保这些范围检查会被删除:对“for”语句中数组的长度进行的显式测试。请注意,只要此模式中存在细微的偏差都会导致检查无法去除,在这种情况下,需向索引中添加一个值。

在 C# 中去除范围检查

//范围检查将被去除
for(int i = 0; i < myArray.Length; i++) 
{
   Console.WriteLine(myArray[i].ToString());
}

//范围检查将无法去除
for(int i = 0; i < myArray.Length + y; i++) 
{ 
   Console.WriteLine(myArray[i+x].ToString());
}

搜索大型不规则数组时,优化操作特别明显,因为此时将同时删除内循环和外循环范围检查。

要求进行变量使用情况跟踪的优化操作

大量 JIT 编译器优化操作都要求 JIT 跟踪定参和局部变量的使用情况,例如,它们在方法主体中的最早以及最近一次使用的时间。在 CLR 1.0 和 1.1 版中,JIT 可以跟踪使用情况的变量总数限制在 64 个之内。例如“寄存操作”就是一个需要进行使用情况跟踪的优化操作。寄存操作是指将变量存储在处理器寄存器中,而不是栈框架中(例如,在内存中)。与对栈框架中的变量进行访问的时间相比,对寄存变量的访问要快得多,即便框架中的变量位于处理器的缓存中也一样。只能对 64 个变量进行寄存,所有其他变量都将推至栈中。除寄存操作以外,另外也有一些优化操作需要进行使用情况跟踪。应该将方法中的定参和局部参数的数量保持在 64 个以下,以确保实现最大数目的 JIT 优化操作。请记住在以后的 CLR 版本中此数目可能会有所变化。

提示:使方法保持简短。要这样做的原因有很多,包括方法内联、寄存操作和 JIT 持续时间的需要。

其他 JIT 优化操作

JIT 编译器还可以执行大量其他的优化操作:常量和副本传播、循环不变式提升以及若干其他操作。需要用来实现优化的编程模式都是免费的,无需花钱购买。

为什么我在 Visual Studio 中没有看到这些优化功能?

当您在 Visual Studio 中使用 Debug(调试)菜单或按下 F5 键启动应用程序时,无论您生成的是发行版还是调试版,所有的 JIT 优化功能都将被禁用。当托管应用程序通过调试器启动时,即使它不是该应用程序的调试版本,JIT 也会发出非优化的 x86 指令。如果您希望 JIT 发出优化代码,那么请从 Windows 资源管理器中启动该应用程序,或者在 Visual Studio 中使用 CTRL+F5 组合键。如果希望查看优化的反汇编程序,并将其与非优化代码进行对比,则可以使用 cordbg.exe。

提示:使用 cordbg.exe 可以查看 JIT 发出的优化和非优化代码的反汇编程序。使用 cordbg.exe 启动该应用程序后,可以通过键入以下代码来设置 JIT 模式:
(cordbg) mode JitOptimizations 1
// JIT 将生成优化的代码

(cordbg) mode JitOptimizations 0

JIT 将生成可调试(非优化)代码。

值类型

CLR 可提供两组不同的类型:引用类型和值类型。引用类型总是分配到托管堆中,并按引用传递(正如它的名称所暗示的)。值类型分配到栈中或在堆中内联为对象的一部分,默认情况下按值传递,不过您也可以按引用来传递它们。分配值类型时,所需的开销非常小,假设它们总是又小又简单,当它们作为参数进行传递时开销也会很小。正确使用值类型的一个很好的示例就是包含 xy 坐标的 Point 值类型。

Point 值类型

struct Point
{
   public int x;
   public int y;
   
   //
}

值类型也可以视为对象,例如,可以在值类型上调用对象方法,它们可以转换为对象,或传递到需要使用对象的位置。无论使用什么方法,只要将值类型转换为引用类型,都需要经过装箱 (Boxing) 处理。对值类型进行装箱处理时,会在托管堆中分配一个新对象,而值则复制到此新对象中。这项操作将占用很大的系统开销,还可能会降低或完全消除通过使用值类型而获得的性能。将装箱的类型隐式或显式转换回值类型的过程称为取消装箱 (Unboxed)。

装箱/取消装箱值类型

C#:

int BoxUnboxValueType()
{
   int i = 10;
   object o = (object)i; //i 被装箱
   return (int)o + 3; //i 被取消装箱
}

MSIL:

.method private hidebysig instance int32
        BoxUnboxValueType() cil managed
{
  // 代码大小       20 (0x14)
  .maxstack  2
  .locals init (int32 V_0,
           object V_1)
  IL_0000:  ldc.i4.s   10
  IL_0002:  stloc.0
  IL_0003:  ldloc.0
  IL_0004:  box        [mscorlib]System.Int32
  IL_0009:  stloc.1
  IL_000a:  ldloc.1
  IL_000b:  unbox      [mscorlib]System.Int32
  IL_0010:  ldind.i4
  IL_0011:  ldc.i4.3
  IL_0012:  add
  IL_0013:  ret
} // 方法 Class1::BoxUnboxValueType 结束

如果实现自定义值类型(C# 中的结构),则应该考虑覆盖 ToString 方法。如果不覆盖此方法,那么对值类型上的 ToString 的调用将导致该类型被装箱。对于从 System.Object 继承的其他方法也是如此。在这种情况下,请使用 Equals 来进行覆盖,尽管 ToString 很可能是最常用的调用方法。如果您希望了解值类型是否被装箱以及何时装箱,可以使用 ildasm.exe 实用程序在 MSIL 中查找 box 指令(如上所述)。

覆盖 C# 中的 ToString() 方法以防止装箱

struct Point
{
   public int x;
   public int y;

   //此操作将防止在调用 ToString 时对类型进行装箱
   public override string ToString()
   {
      return x.ToString() + "," + y.ToString();
   }
}

请注意,在创建集合(例如,浮点数组列表)时,添加到集合中的每一项都将进行装箱。您应该考虑使用数组或为值类型创建自定义集合类。

使用 C# 中的集合类时进行隐式装箱

ArrayList al = new ArrayList();
al.Add(42.0F); //由于 Add() 接受对象因此进行隐式装箱
float f = (float)al[0]; //取消装箱

异常处理

通常,错误条件都将作为常规流程控制使用。在此情况下,如果试图通过编程将用户添加到 Active Directory 实例中,则只能试着添加该用户,如果系统返回 E_ADS_OBJECT_EXISTS HRESULT,则说明它们已经存在于该目录中。此外,您也可以通过搜索目录查找该用户,如果搜索失败则只需添加该用户。

按照常规流程控制使用错误,在 CLR 环境中会降低性能。CLR 中的错误处理是借助结构化异常处理实现的。引发异常之前,托管异常的开销非常小。在 CLR 中,引发异常时,需要使用堆栈遍历为已引发的异常找到相应的异常处理程序。堆栈遍历是一种开销较大的操作。正如它的名称所表示的,异常应该用于异常或意外的情况。

提示:对于以性能为中心的方法,请返回预期结果的枚举结果,而不是引发异常。
提示:有多种 .NET CLR 异常性能计数器都可以通知您在应用程序中引发了多少异常。
提示:如果您使用 VB.NET 来使用除 On Error Goto 以外的异常,错误对象就是不必要的开销。

线程和同步

CLR 提供丰富的线程和同步功能,包括创建自己的线程、线程池和各种同步原语的能力。在充分利用 CLR 中支持的线程之前,应该仔细考虑一下线程的用法。请记住,添加线程实际上会降低吞吐量,而不会增加吞吐量,但它肯定会增加内存的利用率。在将要在多处理器计算机上运行的服务器应用程序中,采用并行操作(尽管这取决于将要执行多少锁争用,例如,序列化执行方式)来添加线程可以显著地提高吞吐量;在客户端应用程序中,添加显示活动和/或进度的线程可以提高反应性能(低吞吐量开销)。

如果应用程序中的线程不是专门用于特定任务的线程,或者关联有特殊的状态,则应该考虑使用线程池。如果您过去使用过 Win32 线程池,那对 CLR 线程池也一定会比较熟悉。每个托管进程仅存在一个线程池实例。线程池可以智能地识别出它所创建的线程数量,并且会根据计算机上的负载对自身进行调节。

要讨论线程处理,就必须讨论同步。所有由多线程为应用程序带来的吞吐量收益都可能会因为同步逻辑编写不正确而全部丧失。锁定的粒度会大大影响应用程序的总体吞吐量,这是因为创建和管理锁会带来系统开销,并且锁定很可能会序列化执行步骤所致。我将使用在树中添加节点的示例来说明这一观点。例如,如果树将成为共享的数据结构,则多线程需要在执行应用程序期间对其进行访问,而且您需要对该树进行同步访问。您可以选择在添加节点的同时锁定整个树,这意味着您只会在单一锁定时带来开销,但其他试图访问该树的线程都可能会因此而阻塞。这将是一个粗粒度的锁定示例。或者,您可以在遍历该树时锁定每个节点,这意味着您会在每个节点上创建锁时带来开销,但是其他线程不会因此而阻塞,除非它们试图访问您已经锁定的特定节点。这是细粒度的锁定示例。仅为要对其进行操作的子树添加锁也许是更合适的锁定粒度。请注意,在本示例中,您可能会使用共享锁 (RWLock),因为只有这样才能让多个读者同时进行访问。

执行同步操作时最简单有效的方法就是使用 System.Threading.Interlocked 类。Interlocked 类提供大量低级别的原子操作:IncrementDecrementExchangeCompareExchange

在 C# 中使用 System.Threading.Interlocked 类

using System.Threading;
//...
public class MyClass
{
   void MyClass() //构造函数
   {
      //以原子方式递增全局实例计数器的值
      Interlocked.Increment(ref MyClassInstanceCounter);
   }

   ~MyClass() //终结器
   {
      //以原子方式递减全局实例计数器的值
      Interlocked.Decrement(ref MyClassInstanceCounter);
      //... 
   }
   //...
}

最常用的同步机制可能是监测器 (Monitor) 或临界区 (Critical Section)。监测器锁可直接使用,也可以借助 C# 中的 lock 关键字来使用。对于给定的对象来说,lock 关键字会对特定的代码块进行同步访问。从性能的角度来说,如果监测器锁的争用率较低,则系统开销相对较小;但是如果其争用率较高,系统开销也会相对较大。

C# lock 关键字

//线程试图获取
 锁
//和代码块
lock(mySharedObject)
{
   //如果块中包含锁,
   //线程只能执行此块中的代码
}//线程释放锁

RWLock 提供的是共享锁定机制:例如,该锁可以在“读者”之间共享,但是不能在“作者”之间共享。在这种锁也适用的情况下,使用 RWLock 可以比使用监测器带来更好的吞吐量,它每次只允许一位读者或作者获得该锁。System.Threading 命名空间也包括 Mutex 类。Mutex 是一种同步原语,可用来进行跨进程的同步操作。请注意,它比临界区的开销要大很多,仅当需要进行跨进程的同步操作时才应使用它。

反射

反射是由 CLR 提供的一种机制,用于在运行时通过编程方式获得类型信息。反射在很大程度上取决于嵌入在托管程序集中的元数据。许多反射 API 都要求搜索并分析元数据,这些操作的开销都很大。

这些反射 API 可以分为三个性能区间:类型比较、成员枚举和成员调用。这些区间的系统开销一直在变大。类型比较操作,例如 C# 中的 typeofGetTypeisIsInstanceOfType 等,都是开销最小的反射 API,尽管它们的实际开销一点也不小。成员枚举操作可以通过编程方式对类的方法、属性、字段、事件、构造函数等进行检查。例如,可能会在设计时的方案中使用这一类的成员枚举操作,在这种情况下,此操作将枚举 Visual Studio 中的 Property Browser(属性浏览器)的 Customs Web Controls(自定义 Web 控件)的属性。那些用于动态调用类成员或动态发出 JIT 并执行某个方法的的反射 API 是开销最大的反射 API。当然,如果需要动态加载程序集、类型实例化以及方法调用,还存在一种后期绑定方案,但是这种松散的耦合关系需要进行明确的性能权衡。一般情况下,应该在对性能影响很大的代码路径中避免使用反射 API。请注意,尽管您没有直接使用反射,但是您使用的 API 可能会使用它。因此,也要注意是否间接使用了反射 API。

后期绑定

后期绑定调用是一种在内部使用反射的功能。Visual Basic.NET 和 JScript.NET 都支持后期绑定调用。例如,使用变量之前您不必进行声明。后期绑定对象实际上是类型对象,可以在运行时使用反射将该对象转换为正确的类型。后期绑定调用比直接调用要慢几个数量级。除非您确实需要后期绑定行为,否则应该避免在性能关键代码路径中使用它。

提示:如果您正在使用 VB.NET,且并不一定需要后期绑定,您可以在源文件的顶部包含 Option Explicit OnOption Strict On 以便通知编译器拒绝后期绑定。这些选项将强制您进行声明,并要求您设置变量类型并关闭隐式转换。

安全性

安全性是必要的而且也是主要的 CLR 的组成部分,使用它时会降低性能。当代码为 Fully Trusted(完全信任)且安全策略为默认设置时,安全性对应用程序的吞吐量和启动时间的影响会很小。对代码持不完全信任态度(例如,来自 Internet 或 Intranet 区域的代码)或缩小 MyComputer Grant Set 都将增加安全性的性能开销。

COM 互操作和平台调用

COM 互操作和平台调用会以几乎透明的方式为托管代码提供本机 API,通常调用大多数本机 API 时都不需要任何特殊代码,但是可能需要使用鼠标进行多次单击。正如您所预计的,从托管代码中调用本机代码会带来开销,反之亦然。这笔开销由两部分组成:一部分是固定开销,此开销与在本机代码和托管代码之间进行的转换有关;另一部分是可变开销,此开销与那些可能要用到的参数封送和返回值有关。COM 互操作和平台调用的固定开销在开销中占的比例较小:通常不超过 50 条指令。在各托管类型之间进行封送处理的开销取决于它们在边界两侧的表示形式的相似程度。需要进行大量转换的类型开销相对较大。例如,CLR 中的所有字符串都为 Unicode 字符串。如果要通过平台调用需要 ANSI 字符数组的 Win32 API,则必须缩小该字符串中的每个字符。但是,如果是将托管的整数数组传递到需要本机整数数组的类型中时,就不需要进行封送处理。

由于存在与调用本机代码相关的性能开销,因此您应该确保该开销是合理的开销。如果您打算进行本机调用,请确保本机调用所做的工作使得因执行此调用而产生的性能开销划算,即尽量使方法“小而精”而非“大而全”。测量本机调用开销的一种好方法是测量不接受任何参数也不具备任何返回值的本机方法的性能,然后再测量您希望调用的本机方法的性能。它们之间的差异即封送处理的开销。

提示:应创建“小而精”的 COM 互操作和平台调用,而不是“大而全”的调用,并确保调用的开销对于调用的工作量是划算的。

请注意,不存在与托管线程相关的线程模式。当您打算进行 COM 互操作调用时,需要确保已将执行调用的线程初始化为正确的 COM 线程模式。此操作通常是使用 MTAThreadAttribute 和 STAThreadAttribute 来实现的(尽管也可以通过编程来实现)。

性能计数器

有大量的 Windows 性能计数器可供 .NET CLR 使用。当开发人员首次诊断性能问题,或试图识别托管应用程序的性能特点时,这些性能计数器就是他们可以选择的武器。我已经简要介绍了几个与内存管理和异常有关的性能计数器。在 CLR 和 .NET Framework 中,性能计数器几乎无处不在。通常,这些性能计数器都可以使用且对系统无害,它们的开销较低,而且不会改变应用程序的性能特征。

其他工具

除了性能计数器和 CLR 分析器以外,您还需要使用常规的分析器来确定应用程序中的哪些方法花费的时间最多,且最常被调用。这些方法将是您需要最先进行优化的方法。有多种支持托管代码的商用分析器可供使用,包括 Compuware 的 DevPartner Studio Professional Edition 7.0 和 Intel® 的 VTune™ Performance Analyzer 7.0。Compuware 还生产一种免费的托管代码分析器,名为 DevPartner Profiler Community Edition。

小结

本文只是从性能的角度初步介绍了 CLR 和 .NET Framework。在 CLR 和 .NET Framework 中,还有许多别的方面也会对应用程序的性能产生影响。我最希望告诉各位开发人员的是:请不要对您的应用程序的目标平台以及您正在使用的 API 的性能做任何的假设。请测量它们!

祝您成功。

资源

 

 

版权所有:UML软件工程组织