编写High-Performance托管应用程序:入门

 

格雷戈·诺里斯金
Microsoft CLR 性能团队

2003 年 6 月

适用于:
   Microsoft® .NET Framework

总结:从性能角度了解.NET Framework的公共语言运行时。 了解如何确定托管代码性能最佳做法以及如何衡量托管应用程序的性能。 (19 个打印页)

下载 CLR 探查器。 (330KB)

目录

杂耍作为软件开发的隐喻
.NET 公共语言运行时
托管数据和垃圾回收器
分配配置文件
分析 API 和 CLR 探查器
托管服务器 GC
终止
释放模式
弱引用说明
托管代码和 CLR JIT
值类型
异常处理
线程处理和同步
反射
后期绑定
安全性
COM 互操作和平台调用
性能计数器
其他工具
结论
资源

杂耍作为软件开发的隐喻

杂耍是描述软件开发过程的一个很好的比喻。 杂耍通常需要至少三个项目,但可以尝试杂耍的项目数没有上限。 当你开始学习如何杂耍时,你会发现你watch每个球,当你抓住和扔球。 随着你的进步,你开始专注于球的流动,而不是每个单独的球。 当你掌握杂耍时,你可以再次专注于一个球,平衡你的鼻子上的球,同时继续杂耍其他人。 你直观地知道球会在哪里,并可以把你的手放在正确的位置抓住和扔他们。 那么,这与软件开发有何类似呢?

软件开发过程中的不同角色交错了不同的“三位一体”:项目和项目经理会处理功能、资源和时间,而软件开发人员则兼顾正确性、性能和安全性。 人们总是可以尝试杂耍更多的项目,但正如任何杂耍的学生可以证明的那样,添加一个球会使保持球在空中的难度成倍增加。 从技术上讲,如果你杂耍不到三个球,你根本不会杂耍。 如果作为软件开发人员,你没有考虑所编写的代码的正确性、性能和安全性,则可能会认为你没有完成工作。 当你最初开始考虑正确性、性能和安全性时,你会发现自己必须一次关注一个方面。 当它们成为你日常实践的一部分时,你会发现你不需要专注于特定方面,它们只是你工作方式的一部分。 掌握它们后,你将能够直观地做出权衡,并适当地集中精力。 和杂耍一样,练习是关键。

编写高性能代码具有三位一体:设置目标、度量和了解目标平台。 如果你不知道代码必须有多快,你如何知道何时完成? 如果不衡量和分析代码,如何知道何时实现了目标,或者为什么没有达到目标? 如果你不了解目标平台,在未达到目标的情况下,如何知道要优化的内容。 这些原则通常适用于高性能代码的开发,无论面向的平台如何。 如果不提及这三位一体,任何关于编写高性能代码的文章都不会完成。 尽管这三者都同样重要,但本文将重点介绍后两个方面,因为它们适用于编写面向 Microsoft® .NET Framework的高性能应用程序。

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

  1. 设置性能目标
  2. 测量、度量,然后测量更多内容
  3. 了解应用程序面向的硬件和软件平台

.NET 公共语言运行时

.NET Framework的核心是公共语言运行时 (CLR) 。 CLR 为代码提供所有运行时服务;实时编译、内存管理、安全性和许多其他服务。 CLR 设计为高性能。 也就是说,有一些方法可以利用这种性能,也可以阻止它。

本文的目的是从性能角度概述公共语言运行时,确定托管代码性能最佳做法,并演示如何衡量托管应用程序的性能。 本文并不详尽地讨论.NET Framework的性能特征。 在本文中,我将定义性能,包括吞吐量、可伸缩性、启动时间和内存使用情况。

托管数据和垃圾回收器

开发人员在性能关键型应用程序中使用托管代码的主要考虑因素之一是 CLR 内存管理的成本,这是由垃圾回收器 (GC) 执行的。 内存管理成本是与类型实例关联的内存分配成本、在实例生存期内管理该内存的成本以及不再需要该内存时释放该内存的成本的函数。

托管分配通常非常便宜;在大多数情况下,花费的时间少于 C/C++ mallocnew。 这是因为 CLR 不需要扫描可用列表来查找下一个可用连续内存块,大到足以容纳新对象;它保留指向内存中下一个可用位置的指针。 可以将托管堆分配视为“堆栈类似”。 如果 GC 需要释放内存来分配新对象,则分配可能会导致集合,在这种情况下,分配比 mallocnew更昂贵。 固定的对象也会影响分配成本。 固定对象是已指示 GC 在集合期间不要移动的对象,通常是因为对象的地址已传递到本机 API。

与 或 new不同,malloc在对象的生存期内管理内存需要相关的成本。 CLR GC 是代系的,这意味着并不总是收集整个堆。 但是,GC 仍需要知道正在收集的堆部分的堆根对象的其余部分是否有任何活动对象。 包含对年轻一代对象进行引用的对象的内存在对象的生存期内管理成本高昂。

GC 是代系标记和扫描垃圾回收器。 托管堆包含三代;第 0 代包含所有新对象,第 1 代包含略长生存期的对象,第 2 代包含生存期较长的对象。 GC 将收集堆的最小部分,以释放足够的内存供应用程序继续。 一代的集合包括所有年轻一代的集合,在本例中,第 1 代集合也收集第 0 代。 第 0 代根据处理器缓存的大小和应用程序的分配速率动态调整大小,收集时间通常不到 10 毫秒。 第 1 代根据应用程序的分配速率动态调整大小,收集通常需要 10 到 30 毫秒。 第 2 代大小将取决于应用程序的分配配置文件,以及收集所需的时间。 正是这些第 2 代集合对管理应用程序内存的性能成本影响最大。

提示 GC 是自我优化,会根据应用程序内存要求自行调整。 在大多数情况下,以编程方式调用 GC 会妨碍优化。 通过调用 GC 来“帮助” GC。收集 很可能不会提高应用程序性能。

GC 可能会在集合期间重定位活动对象。 如果这些对象很大,则重定位成本很高,因此这些对象在称为“大型对象堆”的堆的特殊区域中分配。 大型对象堆已收集,但未压缩,例如,大型对象不会重定位。 大型对象是大于 80kb 的对象。 请注意,这在 CLR 的未来版本中可能会更改。 当需要收集大型对象堆时,它会强制使用完整集合,并在第 2 代收集期间收集大型对象堆。 大型对象堆中对象的分配和死亡率可能会对管理应用程序内存的性能成本产生重大影响。

分配配置文件

托管应用程序的总体分配配置文件将定义垃圾回收器在管理与应用程序关联的内存时必须具备的难度。 GC 管理内存的难度越大,GC 花费的 CPU 周期数就越大,CPU 运行应用程序代码所花费的时间就越少。 分配配置文件是分配的对象数、这些对象的大小及其生存期的函数。 缓解 GC 压力的最明显方法是分配更少的对象。 使用面向对象的设计技术实现扩展性、模块化和重用的应用程序几乎总是会导致分配数增加。 抽象和“优雅”存在性能损失。

GC 友好的分配配置文件将在应用程序开头分配一些对象,然后在应用程序的生存期内生存,然后所有其他对象生存期较短。 生存期较长的对象将包含对短生存期对象的很少引用或不包含引用。 由于分配配置文件偏离了这一点,GC 必须更加努力地管理应用程序内存。

GC 不友好的分配配置文件将有许多对象生存到第 2 代然后死亡,或者将有许多短期对象在大型对象堆中分配。 生存时间足够长、进入第 2 代然后死亡的对象是管理成本最高的对象。 正如我在前面提到的旧一代对象(在 GC 期间包含对年轻一代中的对象的引用)也会增加集合的成本。

典型的实际分配配置文件位于上述两个分配配置文件之间。 分配配置文件的一个重要指标是在 GC 中花费的总 CPU 时间的百分比。 可以从 GC 性能计数器中的 .NET CLR 内存百分比时间 获取此数字。 如果此计数器的平均值高于 30%,则可能应考虑仔细查看分配配置文件。 这不一定意味着分配配置文件是“错误的”;存在一些内存密集型应用程序,其中此级别的 GC 是必需的且合适。 如果遇到性能问题,应首先查看此计数器;它应立即显示分配配置文件是否是问题的一部分。

提示 如果 .NET CLR Memory: % Time in GC 性能计数器指示应用程序平均花费超过 30% 的时间在 GC 中,则应仔细查看分配配置文件。

提示 与第 2 代相比,GC 友好的应用程序具有明显多于第 0 代的集合。 可以通过比较 NET CLR Memory: # Gen 0 Collections 和 NET CLR Memory: # Gen 2 Collections 性能计数器来建立此比率。

分析 API 和 CLR 探查器

CLR 包括一个功能强大的分析 API,它允许第三方为托管应用程序编写自定义探查器。 CLR 探查器是一种不受支持的分配分析示例工具,由 CLR 产品团队编写,使用此分析 API。 CLR Profiler 允许开发人员查看其管理应用程序的分配配置文件。

图 1 CLR 探查器主窗口

CLR Profiler 包括分配配置文件的一些非常有用的视图,包括分配类型的直方图、分配和调用图、显示不同代系的 GC 的时间线以及这些集合后托管堆的结果状态,以及显示按方法分配和程序集加载的调用树。

图 2 CLR 探查器分配图

提示 有关如何使用 CLR Profiler 的详细信息,请参阅 zip 中包含的自述文件。

请注意,CLR 探查器具有高性能开销,并且会显著更改应用程序的性能特征。 使用 CLR 探查器运行应用程序时,紧急压力 bug 可能会消失。

托管服务器 GC

CLR 提供了两种不同的垃圾回收器:工作站 GC 和服务器 GC。 控制台和Windows 窗体应用程序托管工作站 GC,ASP.NET 托管服务器 GC。 服务器 GC 针对吞吐量和多处理器可伸缩性进行优化。 服务器 GC 在集合的整个持续时间内暂停运行托管代码的所有线程,包括标记阶段和扫描阶段,并且 GC 在专用高优先级 CPU 关联线程上进程可用的所有 CPU 上并行发生。 如果线程在 GC 期间运行本机代码,则这些线程仅在本机调用返回时暂停。 如果要构建要在多处理器计算机上运行的服务器应用程序,则强烈建议使用服务器 GC。 如果中的应用程序不是由 ASP.NET 托管的,则必须编写显式托管 CLR 的本机应用程序。

提示 如果要生成可缩放的服务器应用程序,请托管服务器 GC。 请参阅 为托管应用实现自定义公共语言运行时主机

工作站 GC 针对客户端应用程序通常需要的低延迟进行优化。 人们不希望在 GC 期间在客户端应用程序中出现明显的暂停,因为客户端性能通常不是通过原始吞吐量来衡量的,而是通过感知的性能来衡量的。 工作站 GC 执行并发 GC,这意味着它在托管代码仍在运行时执行标记阶段。 GC 仅在需要执行扫描阶段时暂停运行托管代码的线程。 在工作站 GC 中,GC 仅在一个线程上完成,因此仅在一个 CPU 上完成。

终止

CLR 提供了一种机制,即在释放与类型实例关联的内存之前自动完成清理。 此机制称为“最终化”。 通常,Finalization 用于释放本机资源,在本例中,数据库连接对象正在使用的操作系统句柄。

最终完成是一项成本高昂的功能,会增加 GC 上的压力。 GC 跟踪需要在可完成队列中完成的对象。 如果在集合期间,GC 发现某个对象不再处于活动状态,但需要最终确定,则该对象在“可完成队列”中的条目将移动到 FReachable Queue。 最终确定发生在名为“终结器线程”的单独线程上。 由于在执行终结器期间可能需要对象的整个状态,因此对象及其指向的所有对象都将提升到下一代。 与 对象关联的内存(或对象图)仅在以下 GC 期间释放。

需要释放的资源应尽可能小地包装在 Finalizable 对象中;例如,如果类需要对托管和非托管资源的引用,则应将非托管资源包装在新的 Finalizable 类中,并使该类成为类的成员。 父类不应为 Finalizable。 这意味着,假设在包含非托管资源的类) 中不保存对父类的引用,则仅升级包含非托管资源的类 (。 另一个需要记住的事情是,只有一个最终线程。 如果终结器导致此线程被阻止,则不会调用后续终结器,资源将不会释放,并且应用程序将泄漏。

提示 终结器应尽可能简单,绝不应阻止。

提示 仅将需要清理的非托管对象周围的包装类设为可终结的。

可将最终完成视为引用计数的替代方法。 实现引用计数的对象跟踪有多少其他对象引用了它 (这可能会导致一些非常已知的问题) ,以便在引用计数为零时释放其资源。 CLR 不实现引用计数,因此它需要提供一种机制,以便在不再保留对对象的引用时自动释放资源。 最终完成就是这种机制。 通常只有在未明确知道需要清理的对象生存期的情况下,才需要最终完成。

释放模式

如果对象的生存期显式已知,则应预先释放与对象关联的非托管资源。 这称为“释放”对象。 释放模式是通过 IDisposable 接口 (实现的,但自行实现该模式) 是微不足道的。 如果希望使预先完成可用于类(例如,使类的实例可释放),则需要让对象实现 IDisposable 接口,并为 Dispose 方法提供实现。 在 Dispose 方法中,你将调用终结器中的相同清理代码,并通知 GC 它不再需要通过调用 GC 来终结对象 。SuppressFinalization 方法。 最好让 Dispose 方法和终结器调用一个通用的终结函数,以便只需维护清理代码的一个版本。 此外,如果 对象的语义使 Close 方法比 Dispose 方法更具逻辑性,则还应实现 Close :在这种情况下,数据库连接或套接字在逻辑上是“关闭的”。 Close 只需调用 Dispose 方法即可。

最好为具有终结器的类提供 Dispose 方法;例如,永远无法确定该类的使用方式,例如,其生存期是否明确已知。 如果你正在使用的类实现了 Dispose 模式,并且你明确知道何时完成了对象,则肯定调用 Dispose

提示 为所有可终结的类提供 Dispose 方法。

提示 禁止在 Dispose 方法中完成。

提示 调用通用清理函数。

提示 如果正在使用的对象实现了 IDisposable,并且你知道不再需要该对象,请调用 Dispose。

C# 提供了一种非常方便的方法来自动释放对象。 关键字 (keyword) using 允许标识代码块,之后将对多个可释放对象调用 Dispose

C# 使用 关键字 (keyword)

using(DisposableType T)
{
   //Do some work with T
}
//T.Dispose() is called automatically

弱引用说明

对堆栈上、寄存器中、另一个对象或另一个 GC 根中的对象的任何引用都将在 GC 期间使对象保持活动状态。 这通常是一件非常好的事情,因为它通常意味着应用程序不是使用该对象完成的。 但是,在某些情况下,你希望具有对对象的引用,但不希望影响其生存期。 在这些情况下,CLR 提供了一种称为弱引用的机制来执行此操作。 任何强引用(例如,根对象的引用)都可以转换为弱引用。 你可能想要使用弱引用的一个示例是,如果想要创建可以遍历数据结构但不应影响对象的生存期的外部游标对象。 另一个示例是,如果要创建在内存压力时刷新的缓存;例如,当 GC 发生时。

在 C 中创建弱引用#

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

//Create weak reference
WeakReference wr = new WeakReference(mrt); 
mrt = null; //object is no longer rooted
//...

//Has object been collected?
if(wr.IsAlive)
{
   //Get a strong reference to the object
   mrt = wr.Target;
   //object is rooted and can be used again
}
else
{
   //recreate the object
   mrt = new MyRefType();
}

托管代码和 CLR JIT

托管程序集是托管代码的分发单元,它包含一种独立于处理器的语言,称为 Microsoft 中间语言 (MSIL 或 IL) 。 CLR 实时 (JIT) 将 IL 编译为优化的本机 X86 指令。 JIT 是一个优化编译器,但由于编译发生在运行时,并且仅在第一次调用方法时进行,因此需要将其进行的优化次数与执行编译所需的时间相平衡。 对于服务器应用程序来说,这通常并不重要,因为启动时间和响应能力通常不是问题,但对于客户端应用程序至关重要。 请注意,通过在安装时使用 NGEN.exe 进行编译,可以缩短启动时间。

JIT 完成的许多优化没有与之关联的编程模式,例如,你无法为它们显式编码,但有一个数字可以这样做。 下一部分将讨论其中的一些优化。

提示 通过使用 NGEN.exe 实用工具在安装时编译应用程序,缩短客户端应用程序的启动时间。

方法内联

存在与方法调用相关的成本;参数需要推送到堆栈上或存储在寄存器中,需要执行 prolog 和 epilog 方法,等等。 只需将所调用方法的方法体移动到调用方主体中,即可避免某些方法的调用成本。 这称为方法内线。 JIT 使用许多启发法来确定方法是否应内联。 下面是一个列表,其中更重要 (注意,这并非详尽无遗) :

  • 大于 32 字节的 IL 的方法将不会内联。
  • 虚拟函数未内联。
  • 具有复杂流控制的方法不会内联。 复杂流控制是本例 switchwhile中或 以外的if/then/else;任何流控制。
  • 包含异常处理块的方法不会内联,但引发异常的方法仍然是内联的候选项。
  • 如果方法的任何形参是结构,则不会内联该方法。

我会仔细考虑对这些启发式方法进行显式编码,因为它们可能会在 JIT 的未来版本中更改。 不要损害方法的正确性,以尝试保证它将内联。 有趣的是, inline C++ 中的 和 __inline 关键字不能保证编译器将内联方法 (但 __forceinline) 。

属性 get 和 set 方法通常是内联的良好候选项,因为它们通常只需初始化私有数据成员。

**HINT **请勿在尝试保证内联时损害方法的正确性。

范围检查消除

托管代码的众多优势之一是自动范围检查;每次使用 array[index] 语义访问数组时,JIT 都会发出检查,以确保索引位于数组的边界内。 在具有大量迭代和每次迭代执行的少量指令的循环上下文中,这些范围检查的成本可能很高。 在某些情况下,JIT 会检测到这些范围检查是不必要的,并且会消除循环正文中的检查,只需在循环执行开始之前检查一次。 在 C# 中,有一种编程模式来确保消除这些范围检查:显式测试“for”语句中数组的长度。 请注意,与此模式的细微偏差将导致无法消除检查,在本例中,向索引添加值。

C 中的范围检查消除#

//Range check will be eliminated
for(int i = 0; i < myArray.Length; i++) 
{
   Console.WriteLine(myArray[i].ToString());
}

//Range check will NOT be eliminated
for(int i = 0; i < myArray.Length + y; i++) 
{ 
   Console.WriteLine(myArray[i+x].ToString());
}

例如,搜索大型交错数组时,优化特别明显,因为内部和外部循环的范围检查都被消除。

需要变量使用情况跟踪的优化

大量 JIT 编译器优化要求 JIT 跟踪形参和局部变量的使用情况;例如,首次使用它们的时间和最后一次在方法主体中使用它们的时间。 在 CLR 版本 1.0 和 1.1 中,JIT 将跟踪其使用情况的变量总数限制为 64。 需要使用情况跟踪的优化示例是“注册”。 注册是变量存储在处理器寄存器中而不是堆栈帧上(例如,存储在 RAM 中)中时。 与堆栈帧上的变量相比,对注册变量的访问速度要快得多,即使帧上的变量恰好位于处理器缓存中。 只会考虑 64 个变量进行注册;所有其他变量都将推送到堆栈上。 除注册之外,还有其他依赖于使用情况跟踪的优化。 方法的形参和局部变量数应保持在 64 以下,以确保 JIT 优化的最大数目。 请记住,此数字可能会因 CLR 的未来版本而更改。

提示 使方法保持简短。 导致这种情况的原因有很多,包括方法内联、注册和 JIT 持续时间。

其他 JIT 优化

JIT 编译器执行许多其他优化:常量和复制传播、循环固定提升以及其他几个优化。 不需要使用显式编程模式来获取这些优化;他们是免费的。

为什么在 Visual Studio 中看不到这些优化?

从“调试”菜单使用“开始”或按 F5 在 Visual Studio 中启动应用程序时,无论已生成“发布”还是“调试”版本,都将禁用所有 JIT 优化。 当托管应用程序由调试器启动时,即使它不是应用程序的调试版本,JIT 也会发出非优化的 x86 指令。 如果希望 JIT 发出优化代码,请从 Windows 资源管理器启动应用程序,或者在 Visual Studio 中使用 CTRL+F5。 如果想要查看优化的反汇编并将其与非优化代码进行对比,可以使用 cordbg.exe。

提示 使用 cordbg.exe 查看 JIT 发出的优化代码和非优化代码的反汇编。 使用 cordbg.exe 启动应用程序后,可以通过键入以下内容来设置 JIT 模式:

(cordbg) mode JitOptimizations 1
JIT's will produce optimized code

(cordbg) mode JitOptimizations 0

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

值类型

CLR 公开两组不同的类型:引用类型和值类型。 引用类型始终在托管堆上分配,并由引用 (传递,顾名思义) 。 值类型作为堆上对象的一部分在堆栈上或内联分配,默认情况下按值传递,不过也可以通过引用传递它们。 值类型分配非常便宜,并且假设它们保持较小且简单,则将其作为参数传递的成本很低。 正确使用值类型的一个很好的示例是包含 xy 坐标的 Point 值类型。

点值类型

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

值类型也可以被视为对象;例如,可以对其调用对象方法,也可以将其强制转换为对象,或者在需要对象的位置传递。 发生这种情况时,值类型将通过名为 Boxing 的过程转换为引用类型。 当值类型为 Boxed 时,在托管堆上分配一个新对象,并将该值复制到新对象中。 这是一项成本高昂的操作,可能会降低或完全否定使用值类型获得的性能。 当装箱类型隐式或显式转换回值类型时,它是未装箱的。

框/取消装箱值类型

C#:

int BoxUnboxValueType()
{
   int i = 10;
   object o = (object)i; //i is Boxed
   return (int)o + 3; //i is Unboxed
}

Msil:

.method private hidebysig instance int32
        BoxUnboxValueType() cil managed
{
  // Code size       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
} // end of method Class1::BoxUnboxValueType

如果在 C#) 中 (结构实现自定义值类型,则应考虑重写 ToString 方法。 如果不重写此方法,则对值类型调用 ToString 将导致类型为 Boxed。 对于从 System.Object 继承的其他方法也是如此,在这种情况下为 Equals,尽管 ToString 可能是最常调用的方法。 如果想要了解值类型是否为 Boxed 以及何时为 Boxed,可以使用 ildasm.exe 实用工具 (在 MSIL 中查找 box 指令,如上面的代码片段) 所示。

在 C# 中重写 ToString () 方法以防止装箱

struct Point
{
   public int x;
   public int y;

   //This will prevent type being boxed when ToString is called
   public override string ToString()
   {
      return x.ToString() + "," + y.ToString();
   }
}

请注意,创建集合时(例如 float 的 ArrayList)时,每个项在添加到集合时都将被装箱。 应考虑使用数组或为值类型创建自定义集合类。

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

ArrayList al = new ArrayList();
al.Add(42.0F); //Implicitly Boxed becuase Add() takes object
float f = (float)al[0]; //Unboxed

异常处理

常见的做法是使用错误条件作为正常流控制。 在这种情况下,尝试以编程方式将用户添加到 Active Directory 实例时,只需尝试添加用户,如果返回E_ADS_OBJECT_EXISTS HRESULT,则表明目录中已存在用户。 或者,可以在目录中搜索用户,然后仅在搜索失败时添加用户。

这种对正常流控制使用错误是 CLR 上下文中的性能反模式。 CLR 中的错误处理是通过结构化异常处理完成的。 在引发托管异常之前,托管异常非常便宜。 在 CLR 中,当引发异常时,需要堆栈遍查来查找引发的异常的相应异常处理程序。 堆栈遍走是一项成本高昂的操作。 应使用异常,顾名思义:在异常或意外情况下。

**HINT**考虑针对性能关键型方法返回预期结果的枚举结果,而不是引发异常。

**HINT **有许多 .NET CLR 异常性能计数器将告诉你应用程序中引发的异常数。

**HINT**如果使用 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() //Constructor
   {
      //Increment a global instance counter atomically
      Interlocked.Increment(ref MyClassInstanceCounter);
   }

   ~MyClass() //Finalizer
   {
      //Decrement a global instance counter atomically
      Interlocked.Decrement(ref MyClassInstanceCounter);
      //... 
   }
   //...
}

最常用的同步机制可能是“监视”或“关键”部分。 可以直接使用监视器锁,也可以使用 lock C# 中的关键字 (keyword) 。 lock 关键字 (keyword) 将给定对象的访问同步到特定代码块。 从性能的角度来看,竞争相当轻的监视器锁相对便宜,但如果受到高度争议,则成本会更高。

C# 锁关键字 (keyword)

//Thread will attempt to obtain the lock
//and block until it does
lock(mySharedObject)
{
   //A thread will only be able to execute the code
   //within this block if it holds the lock
}//Thread releases the lock

RWLock 提供共享锁定机制:例如,“读取器”可以与其他“读取器”共享锁,但“编写器”不能。 在适用的情况下,RWLock 可以产生比使用监视器更好的吞吐量,因为监视器一次只允许单个读取器或编写器获取锁。 System.Threading 命名空间还包括 Mutex 类。 互斥体是允许跨进程同步的同步基元。 请注意,这比关键部分的成本要高得多,并且仅应在需要跨进程同步的情况下使用。

反射

反射是 CLR 提供的一种机制,可用于在运行时以编程方式获取类型信息。 反射在很大程度上依赖于嵌入在托管程序集中的元数据。 许多反射 API 需要搜索和分析元数据,这是一项昂贵的操作。

反射 API 可以分为三个性能存储桶:类型比较、成员枚举和成员调用。 其中每个存储桶的成本逐渐增加。 类型比较操作(在本例中为 C#中的 typeofGetTypeisInstanceOfType 等)是反射 API 中最便宜的,尽管它们并不便宜。 成员枚举允许以编程方式检查类的方法、属性、字段、事件、构造函数等。 例如,在设计时方案中可以使用这些属性,在本例中枚举 Visual Studio 中属性浏览器的海关 Web 控件的属性。 最昂贵的反射 API 是那些允许动态调用类成员,或动态发出 JIT 和执行方法的 API。 当然,有些后期绑定方案需要动态加载程序集、类型的实例化和方法调用,但这种松散耦合需要显式的性能权衡。 通常,在性能敏感的代码路径中应避免使用反射 API。 请注意,虽然你不直接使用反射,但你使用的 API 可能会使用它。 因此,还需注意反射 API 的可传递使用。

后期绑定

后期绑定调用是在幕后使用反射的功能的一个示例。 视觉对象 Basic.NET 和 JScript.NET 都支持后期绑定调用。 例如,在使用变量之前,无需声明变量。 后期绑定对象实际上是对象类型,反射用于在运行时将对象转换为正确的类型。 后期绑定调用比直接调用慢几个数量级。 除非特别需要后期绑定行为,否则应避免在性能关键代码路径中使用后期绑定行为。

提示 如果使用 VB.NET 并且不需要显式后期绑定,可以通过在源文件顶部包括 Option Explicit OnOption Strict On 来告知编译器禁止绑定。 这些选项强制声明变量并强键入变量,并关闭隐式强制转换。

安全性

安全性是 CLR 的必要组成部分,并且具有相关的性能成本。 如果代码为完全信任且安全策略为默认值,则安全性应对应用程序的吞吐量和启动时间产生轻微影响。 部分受信任的代码(例如,来自 Internet 或 Intranet 区域的代码)或缩小 MyComputer 授予集将增加安全性能成本。

COM 互操作和平台调用

COM 互操作和平台调用以几乎透明的方式向托管代码公开本机 API;调用大多数本机 API 通常不需要特殊代码,但可能需要单击几次鼠标。 如你所料,从托管代码调用本机代码会产生相关成本,反之亦然。 此成本有两个组成部分:一个是执行本机代码与托管代码之间的转换相关的固定成本,一个是与任何可能需要的参数和返回值封送相关的可变成本。 COM 互操作和 P/Invoke 成本的固定贡献很小:通常小于 50 条指令。 与托管类型进行封送的成本取决于边界两侧的表示形式的不同程度。 需要大量转换的类型将更加昂贵。 例如,CLR 中的所有字符串都是 Unicode 字符串。 如果通过 P/Invoke 调用需要 ANSI 字符数组的 Win32 API,则必须缩小字符串中的每个字符。 但是,如果在需要本机整数数组的位置传递托管整数数组,则无需封送处理。

由于与调用本机代码相关的性能成本,因此应确保成本合理。 如果要进行本机调用,请确保本机调用所做的工作证明与进行调用相关的性能成本合理 - 保留方法“区块”而不是“闲聊”。衡量本机调用成本的好方法是测量不带参数且没有返回值的本机方法的性能,然后测量要调用的本机方法的性能。 差异将指示封送成本。

提示 进行“Chunky”COM 互操作和 P/Invoke 调用,而不是“聊天”调用,并确保呼叫的成本与调用的工作量是合理的。

请注意,没有与托管线程关联的线程模型。 在进行 COM 互操作调用时,需要确保将要进行调用的线程已初始化为正确的 COM 线程模型。 这通常使用 MTAThreadAttribute 和 STAThreadAttribute (但也可以以编程方式) 完成。

性能计数器

为 .NET CLR 公开了许多 Windows 性能计数器。 在首次诊断性能问题或尝试识别托管应用程序的性能特征时,这些性能计数器应该是开发人员的首选武器。 我已经提到了一些与内存管理和异常相关的计数器。 CLR 和.NET Framework的几乎所有方面都有性能计数器。 这些性能计数器始终可用,并且是非侵入性的;它们的开销较低,不会更改应用程序的性能特征。

其他工具

除了性能计数器和 CLR 探查器之外,还需要使用常规探查器来确定应用程序中哪些方法花费的时间最多且调用频率最高。 这些将是你首先优化的方法。 提供了许多支持托管代码的商业探查器,包括来自 Compuware 的 DevPartner Studio Professional Edition 7.0 和来自 Intel® 的 VTune™ 性能分析器 7.0。 Compuware 还为名为 DevPartner Profiler Community Edition 的托管代码生成免费探查器。

结论

本文刚刚从性能角度开始研究 CLR 和.NET Framework。 CLR 体系结构和.NET Framework的许多其他方面会影响应用程序的性能。 我可以向任何开发人员提供的最佳指导是不要对应用程序所面向的平台和使用的 API 的性能做出任何假设。 测量一切!

快乐杂耍。

资源