.NET Framework中Run-Time技术的性能注意事项

 

伊曼纽尔·尚策
Microsoft Corporation

2001 年 8 月

总结: 本文包括对托管世界中各种技术的调查,以及它们如何影响性能的技术说明。 了解垃圾回收、JIT、远程处理、ValueTypes、安全性等工作。 ) (27 个打印页

目录

概述
垃圾回收
线程池
The JIT
AppDomain
安全性
远程处理
ValueTypes
其他资源
附录:托管服务器运行时

概述

.NET 运行时引入了多项旨在提高安全性、易于开发和性能的先进技术。 作为开发人员,了解每种技术并在代码中有效使用它们非常重要。 运行时提供的高级工具可以轻松生成可靠的应用程序,但使该应用程序快速运行 (,并且始终) 开发人员的责任。

本白皮书应让你更深入地了解 .NET 中工作的技术,并帮助你优化代码以加快速度。 注意:这不是规格表。 那里已经有很多扎实的技术信息。 此处的目标是提供对性能的强烈倾斜的信息,并且可能不会回答你提出的每个技术问题。 如果在此处找不到所寻求的答案,建议在 MSDN 联机库中进一步查找。

我将介绍以下技术,简要概述其用途以及它们影响性能的原因。 然后,我将深入探讨一些较低级别的实现详细信息,并使用示例代码来说明如何加快每种技术的速度。

垃圾回收

基础知识

垃圾回收 (GC) 通过释放不再使用对象的内存,使程序员摆脱常见且难以调试的错误。 在托管代码和本机代码中,对象生存期遵循的常规路径如下所示:

Foo a = new Foo();      // Allocate memory for the object and initialize
...a...                  // Use the object   
delete a;               // Tear down the state of the object, clean up
                        // and free the memory for that object

在本机代码中,需要自己执行所有这些操作。 缺少分配或清理阶段可能会导致难以调试的完全不可预知的行为,而忘记释放对象可能会导致内存泄漏。 公共语言运行时 (CLR) 内存分配的路径非常接近我们刚才介绍的路径。 如果我们添加特定于 GC 的信息,我们最终会生成看起来非常相似的内容。

Foo a = new Foo();      // Allocate memory for the object and initialize
...a...                  // Use the object (it is strongly reachable)
a = null;               // A becomes unreachable (out of scope, nulled, etc)
                        // Eventually a collection occurs, and a's resources
                        // are torn down and the memory is freed

在可以释放对象之前,在两个世界中都执行相同的步骤。 在本机代码中,需要记住在完成对象时释放对象。 在托管代码中,一旦对象不再可访问,GC 就可以收集它。 当然,如果你的资源需要特别注意, (例如,关闭套接字) GC 可能需要帮助才能正确关闭它。 在释放资源之前为清理资源而编写的代码仍然适用,形式为 Dispose () Finalize () 方法。 我稍后将讨论这两者之间的差异。

如果保留指向资源的指针,GC 无法知道你是否打算在将来使用它。 这意味着,在本机代码中用于显式释放对象的所有规则仍然适用,但在大多数情况下,GC 将为你处理所有内容。 无需百分之百地担心内存管理,只需在百分之五的时间担心内存管理。

CLR 垃圾回收器是一代标记和紧凑回收器。 它遵循几个原则,使它能够实现出色的性能。 首先,存在一个概念,即生存期较短的对象往往较小且经常被访问。 GC 将分配图划分为多个称为 系的子图,这样它就可以花尽可能少的时间收集*.* Gen 0 包含年轻且经常使用的对象。 这往往也是最小的,大约需要 10 毫秒才能收集。 由于 GC 可以忽略此收集期间的其他代,因此它提供的性能要高得多。 G1 和 G2 适用于较大较旧的对象,收集频率较低。 发生 G1 集合时,也会收集 G0。 G2 集合是一个完整的集合,并且是 GC 唯一遍历整个图形的时间。 它还智能地使用 CPU 缓存,这些缓存可以针对运行 CPU 的特定处理器优化内存子系统。 这是本机分配中不容易提供的优化,可帮助应用程序提高性能。

何时发生集合?

进行时间分配时,GC 会检查是否需要集合。 它查看集合的大小、剩余内存量以及每一代的大小,然后使用启发式方法做出决策。 在集合发生之前,对象分配的速度通常与 C 或 C++ 一样快 (或) 更快。

发生集合时会发生什么情况?

让我们演练垃圾回收器在回收过程中执行的步骤。 GC 维护指向 GC 堆的根列表。 如果对象处于活动状态,则其位于堆中的位置有一个根。 堆中的对象也可以相互指向。 此指针图是 GC 必须搜索以释放空间的内容。 事件的顺序如下:

  1. 托管堆将其所有分配空间保存在连续块中,当此块小于请求的数量时,将调用 GC。

  2. GC 遵循每个根和之后的所有指针,维护 不可 访问的对象列表。

  3. 无法从任何根访问的每个对象都被视为可收集对象,并标记为收集。

    图 1. 收集前:请注意,并非所有块都可以从根访问!

  4. 从可访问性图中删除对象可使大多数对象可收集。 但是,某些资源需要专门处理。 定义对象时,可以选择编写 Dispose () 方法或 Finalize () 方法 (或两者) 。 我将讨论两者之间的差异,以及稍后何时使用它们。

  5. 集合中的最后一步是压缩阶段。 使用的所有对象都移动到连续块中,并更新所有指针和根。

  6. 通过压缩活动对象并更新可用空间的起始地址,GC 会维护所有可用空间都是连续的。 如果有足够的空间来分配对象,GC 会将控件返回到程序。 如果不是,则引发 OutOfMemoryException

    图 2. 收集后:已压缩可访问的块。 更多可用空间!

有关内存管理的更多技术信息,请参阅 Jeffrey Richter (Microsoft Press (Microsoft Windows 编程应用程序 第 3 章,1999) 。

对象清理

某些对象需要特殊处理才能返回其资源。 此类资源的一些示例包括文件、网络套接字或数据库连接。 仅仅释放堆上的内存是不够的,因为你希望这些资源正常关闭。 若要执行对象清理,可以编写 Dispose () 方法和/或 Finalize () 方法。

Finalize () 方法:

  • 由 GC 调用
  • 不保证按任何顺序或在可预测的时间调用
  • 调用后, 在下一个 GC 之后释放内存
  • 使所有子对象保持活动状态,直到下一个 GC

Dispose () 方法:

  • 由程序员调用
  • 由程序员排序和计划
  • 方法完成后返回资源

仅包含托管资源的托管对象不需要这些方法。 你的程序可能只使用几个复杂的资源,你很可能知道它们是什么以及何时需要它们。 如果你知道这两件事,则没有理由依赖终结器,因为你可以手动执行清理。 要执行此操作的原因有多种,它们都与 终结器队列有关。

在 GC 中,当具有终结器的对象标记为可收集时,该对象及其指向的任何对象都放置在特殊队列中。 一个单独的线程向下执行此队列,调用队列中每个项的 Finalize () 方法。 程序员无法控制此线程或放置在队列中的项的顺序。 GC 可能会将控制权返回到程序,而无需完成队列中的任何对象。 这些对象可能保留在内存中,长时间隐藏在队列中。 自动完成对最终的调用,调用本身不会直接影响性能。 但是,用于最终确定的非确定性模型 肯定会 产生其他间接后果:

  • 在需要在特定时间释放资源的情况下,你将失去终结器的控制权。 假设你打开了一个文件,并且出于安全原因需要关闭它。 即使将 对象设置为 null 并立即强制 GC,文件也会保持打开状态,直到调用其 Finalize () 方法,并且你不知道何时会发生这种情况。
  • 可能无法正确处理需要按特定顺序处置的 N 个对象。
  • 巨大的对象及其子对象可能会占用过多的内存,需要额外的集合并损害性能。 这些对象可能长时间不收集。
  • 要最终完成的小型对象可能具有指向可以随时释放的大型资源的指针。 在处理要完成的对象之前,不会释放这些对象,从而造成不必要的内存压力并强制频繁收集。

图 3 中的状态图演示了对象在完成或处置方面可以采用的不同路径。

图 3. 对象可以采用的处置和最终完成路径

如你所看到的,最终完成会向对象的生存期添加多个步骤。 如果自行释放对象,则可以收集对象,并在下一个 GC 中返回内存。 当需要完成时,必须等到实际方法被调用。 由于无法保证何时发生这种情况,因此可以绑定大量内存,并受最终队列的摆布。 如果对象连接到整个对象树,并且它们都位于内存中,直到最终完成,则这可能会产生极大的问题。

选择要使用的垃圾回收器

CLR 有两种不同的 GC:工作站 (mscorwks.dll) 和服务器 (mscorsvr.dll) 。 在工作站模式下运行时,延迟与其说是空间或效率,不如说是问题。 具有多个处理器的服务器和通过网络连接的客户端可能会承受一些延迟,但吞吐量现在是首要任务。 Microsoft 包含了两个针对每种情况定制的垃圾回收器,而不是将这两种方案都加入到单个 GC 方案中。

服务器 GC:

  • 多处理器 (MP) 可缩放、并行
  • 每个 CPU 一个 GC 线程
  • 程序在标记期间暂停

工作站 GC:

  • 通过在完整集合期间并发运行来最大程度地减少暂停

服务器 GC 旨在实现最大吞吐量,并具有极高的性能缩放。 服务器上的内存碎片是一个比工作站更严重的问题,这使得垃圾回收成为一个有吸引力的主张。 在单处理器方案中,两个收集器的工作方式相同:工作站模式,无并发收集。 在 MP 计算机上,工作站 GC 使用第二个处理器同时运行集合,从而最大限度地减少延迟,同时降低吞吐量。 服务器 GC 使用多个堆和集合线程来最大化吞吐量并更好地缩放。

你可以选择在托管运行时时要使用的 GC。 将运行时间加载到进程中时,可以指定要使用的收集器。 .NET Framework开发人员指南中讨论了如何加载 API。 有关托管运行时并选择服务器 GC 的简单程序的示例,请查看附录。

误区:垃圾回收总是比手动收集慢

实际上,在调用集合之前,GC 比在 C 中手动执行要快得多。这让很多人感到意外,所以值得一些解释。 首先,请注意,查找可用空间是在固定时间内发生的。 由于所有可用空间都是连续的,因此 GC 只需跟随指针并检查是否有足够的空间。 在 C 中,调用 malloc () 通常 会导致搜索可用块的链接列表。 这很耗时,尤其是在堆碎片严重时。 更糟的是,在此过程中,C 运行时的几个实现会锁定堆。 分配或使用内存后,必须更新列表。 在垃圾回收环境中,分配是免费的,并在回收期间释放内存。 更高级的程序员将保留大型内存块,并自行处理该块内的分配。 此方法的问题在于,内存碎片成为程序员面临的一大问题,这迫使他们向其应用程序添加大量内存处理逻辑。 最后,垃圾回收器不会增加很多开销。 分配的速度和速度一样快,并且会自动处理压缩,使程序员能够专注于其应用程序。

将来,垃圾回收器可以执行其他优化,使其更快。 热点识别和更好的缓存使用是可能的,并且可能会产生巨大的速度差异。 更智能的 GC 可以更高效地打包页面,从而最大程度地减少执行过程中发生的页提取次数。 所有这些操作都可以使垃圾回收环境比手动操作更快。

有些人可能想知道为什么 GC 在其他环境(如 C 或 C++)中不可用。 答案是类型。 这些语言允许将指针强制转换为任何类型的指针,因此很难知道指针指的是什么。 在 CLR 等托管环境中,我们可以保证有足够的指针使 GC 成为可能。 托管世界也是唯一可以安全停止线程执行以执行 GC 的地方:在 C++ 中,这些操作要么不安全,要么非常有限。

速度优化

在托管世界中,程序最担心的是 内存保留。 在非托管环境中,你会发现的一些问题在托管环境中不是问题:内存泄漏和悬垂指针在这里并不是一个问题。 相反,程序员在不再需要资源时需要小心,不要让资源保持连接状态。

对于习惯于编写本机代码的程序员来说,最重要的性能启发式也是最简单的方法:跟踪要进行的分配,并在完成后释放它们。 GC 无法知道你不会使用生成的 20KB 字符串(如果该字符串是保留的对象的一部分)。 假设你将此对象隐藏在某个向量中,并且你再也不想使用该字符串。 将字段设置为 null 将允许 GC 稍后收集这些 20KB,即使你仍需要对象用于其他目的。 如果不再需要 对象,请确保不要保留对它的引用。 (就像在本机代码中一样。) 对于较小的对象,这并不是什么问题。 任何熟悉本机代码内存管理的程序员在这里都不会有问题:所有相同的常识规则都适用。 你不必对他们如此偏执。

第二个重要性能问题涉及对象清理。 正如我前面提到的,最终完成对性能有深远的影响。 最常见的示例是非托管资源的托管处理程序:需要实现某种清理方法,这是性能成为问题的地方。 如果依赖于最终确定,则可以自行了解我之前列出的性能问题。 另一个需要记住的是,GC 基本上不知道本机世界的内存压力,因此你可能只是通过在托管堆中保留指针来使用大量非托管资源。 单个指针不会占用大量内存,因此可能需要一段时间才需要集合。 若要解决这些性能问题,同时在内存保留方面仍然安全运行,应为需要特殊清理的所有对象选择一种设计模式。

程序员在处理对象清理时有四个选项:

  1. 实现两者

    这是建议用于对象清理的设计。 这是一个对象,其中包含一些非托管和托管资源的组合。 例如 System.Windows.Forms.Control。 它具有非托管资源 (HWND) ,并且可能托管的资源 (DataConnection 等) 。 如果不确定何时使用非托管资源,可以在 中ILDASM`` 打开程序的清单,并检查引用本机库。 另一种方法是使用 vadump.exe 查看与程序一起加载的资源。 这两种资源都可能让你深入了解你使用的本机资源类型。

    下面的模式为用户提供了一种建议方式,而不是替代清理逻辑 (重写 Dispose (bool) ) 。 这提供了最大的灵活性,以及在从不调用 Dispose () 的情况下实现全部捕获。 将最大速度和灵活性以及安全网方法相结合,使其成为使用的最佳设计。

    示例:

    public class MyClass : IDisposable {
      public void Dispose() {
        Dispose(true);
        GC.SuppressFinalizer(this);
      }
      protected virtual void Dispose(bool disposing) {
        if (disposing) {
          ...
        }
          ...
      }
      ~MyClass() {
        Dispose(false);
      }
    }
    
  2. 仅实现 Dispose ()

    此时,对象只有托管资源,并且你想要确保其清理是确定性的。 此类对象的一个示例是 System.Web.UI.Control

    示例:

    public class MyClass : IDisposable {
      public virtual void Dispose() {
        ...
      }
    
  3. 仅实现 Finalize ()

    这是在极其罕见的情况下需要的,我强烈建议不要这样做。 Finalize () only 对象的含义是,程序员不知道何时收集对象,但使用的是足够复杂的资源,需要特殊清理。 这种情况不应该发生在设计良好的项目中,如果你发现自己在项目中,你应该回去找出问题所在。

    示例:

    public class MyClass {
      ...
      ~MyClass() {
        ...
      }
    
  4. 两者均不实现

    这适用于托管对象,该对象仅指向不可释放或未最终确定的其他托管对象。

建议

处理内存管理的建议应该很熟悉:使用完对象后释放对象,并注意留下指向对象的指针。 在对象清理方面,为具有非托管资源的对象实现 Finalize () Dispose () 方法。 这将防止以后出现意外行为,并强制实施良好的编程做法

此处的缺点是强制用户必须调用 Dispose () 。 这里没有性能损失,但有些人可能会觉得不得不考虑处置他们的对象令人沮丧。 但是,我认为使用有意义的模型是值得的。 此外,这迫使人们更加关注他们分配的对象,因为他们不能盲目地相信 GC 总是照顾他们。 对于来自 C 或 C++ 背景的程序员来说,强制调用 Dispose () 可能是有益的,因为这是他们更熟悉的那种事情。

对于持有非托管资源的对象,应支持 Dispose () ,这些对象位于其下的对象树中的任何位置;但是,Finalize () 只需放置在专门保留这些资源的对象上,例如 OS 句柄或非托管内存分配。 除了支持由父对象的 Dispose () 调用的 Dispose () , 之外,我建议创建小型托管对象作为实现 Finalize () 的“包装器”。 由于父对象没有终结器,因此无论是否调用 Dispose () ,整个对象树都不会在集合中幸存下来。

对于终结器来说,一个很好的经验法则是仅在 需要 终结的最原始对象上使用它们。 假设我有一个大型的托管资源,其中包含一个数据库连接:我可以完成连接本身,但使对象的其余部分可释放。 这样,我就可以调用 Dispose () 并立即释放对象的托管部分,而无需等待连接完成。 请记住:仅在必要时使用 Finalize ()

注意 C 和 C++ 程序员:C# 中的析构函数语义创建 终结器,而不是处置方法!

线程池

基础知识

CLR 的线程池在很多方面都类似于 NT 线程池,并且几乎不需要程序员的新了解。 它有一个等待线程,它可以处理其他线程的块,并在它们需要返回时通知它们,从而释放它们来执行其他工作。 它可以生成新线程并阻止其他线程来优化运行时的 CPU 利用率,从而保证完成最多的有用工作。 它还在线程完成后回收线程,再次启动线程,而无需杀死和生成新线程的开销。 与手动处理线程时,这是一个很大的性能提升,但它不是万能的。 在优化线程化应用程序时,了解何时使用线程池至关重要。

从 NT 线程池了解到的内容:

  • 线程池将处理线程创建和清理。
  • 它为仅) NT 平台 (I/O 线程提供完成端口。
  • 回调可以绑定到文件或其他系统资源。
  • 计时器和等待 API 可用。
  • 线程池使用自上次注入以来的延迟、当前线程数和队列大小等启发式方法确定应处于活动状态的线程数。
  • 共享队列中的线程馈送。

.NET 中的不同点:

  • 它知道托管代码中的线程阻塞 (例如,由于垃圾回收、托管等待) ,并且可以相应地调整其线程注入逻辑。
  • 不能保证为单个线程提供服务。

何时自行处理线程

有效地使用线程池与了解线程所需的内容密切相关。 如果需要服务保证,则需要自行管理。 在大多数情况下,使用池可提供最佳性能。 如果存在硬性限制,并且需要严格控制线程,则无论如何使用本机线程可能更有意义,因此请谨慎处理托管线程。 如果决定自行编写托管代码并处理线程,请确保不会基于每个连接生成线程:这只会损害性能。 根据经验法则,你只应在非常具体的方案中选择自己在托管世界中处理线程,其中存在很少完成的大型耗时任务。 一个示例可能是在后台填充大型缓存,或者将大型文件写出到磁盘。

优化速度

线程池对应处于活动状态的线程数设置了限制,如果其中许多线程被阻止,则池将耗尽。 理想情况下,应将线程池用于生存期较短的非阻塞线程。 在服务器应用程序中,你想要快速高效地回答每个请求。 如果为每个请求启动一个新线程,则会处理大量开销。 解决方案是回收线程,小心清理并在完成后返回每个线程的状态。 以下是线程池在性能和设计方面的主要胜利,以及应充分利用该技术的情况。 线程池会为你处理状态清理,并确保在给定时间使用的最佳线程数。 在其他情况下,自行处理线程可能更有意义。

虽然 CLR 可以使用类型安全来保证进程,以确保 AppDomains 可以共享同一进程,但线程不存在此类保证。 程序员负责编写性能良好的线程,并且你从本机代码获取的所有知识仍然适用。

下面是一个利用线程池的简单应用程序的示例。 它创建一组工作线程,然后让它们执行一个简单的任务,然后再关闭它们。 我已执行了一些错误检查,但此代码与 Framework SDK 文件夹中的“Samples\Threading\Threadpool”下的代码相同。 在此示例中,我们有一些代码创建一个简单的工作项,并使用线程池让多个线程处理这些项,而无需程序员管理它们。 有关详细信息,请查看 ReadMe.html 文件。

using System;
using System.Threading;

public class SomeState{
  public int Cookie;
  public SomeState(int iCookie){
    Cookie = iCookie;
  }
};


public class Alpha{
  public int [] HashCount;
  public ManualResetEvent eventX;
  public static int iCount = 0;
  public static int iMaxCount = 0;
  public Alpha(int MaxCount) {
    HashCount = new int[30];
    iMaxCount = MaxCount;
  }


   //   The method that will be called when the Work Item is serviced
   //   on the Thread Pool
   public void Beta(Object state){
     Console.WriteLine(" {0} {1} :", 
               Thread.CurrentThread.GetHashCode(), ((SomeState)state).Cookie);
     Interlocked.Increment(ref HashCount[Thread.CurrentThread.GetHashCode()]);

     //   Do some busy work
     int iX = 10000;
     while (iX > 0){ iX--;}
     if (Interlocked.Increment(ref iCount) == iMaxCount) {
       Console.WriteLine("Setting EventX ");
       eventX.Set();
     }
  }
};

public class SimplePool{
  public static int Main(String[] args)   {
    Console.WriteLine("Thread Simple Thread Pool Sample");
    int MaxCount = 1000;
    ManualResetEvent eventX = new ManualResetEvent(false);
    Console.WriteLine("Queuing {0} items to Thread Pool", MaxCount);
    Alpha oAlpha = new Alpha(MaxCount);
    oAlpha.eventX = eventX;
    Console.WriteLine("Queue to Thread Pool 0");
    ThreadPool.QueueUserWorkItem(new WaitCallback(oAlpha.Beta),new SomeState(0));
       for (int iItem=1;iItem < MaxCount;iItem++){
         Console.WriteLine("Queue to Thread Pool {0}", iItem);
         ThreadPool.QueueUserWorkItem(new WaitCallback(oAlpha.Beta),
                                   new SomeState(iItem));
       }
    Console.WriteLine("Waiting for Thread Pool to drain");
    eventX.WaitOne(Timeout.Infinite,true);
    Console.WriteLine("Thread Pool has been drained (Event fired)");
    Console.WriteLine("Load across threads");
    for(int iIndex=0;iIndex<oAlpha.HashCount.Length;iIndex++)
      Console.WriteLine("{0} {1}", iIndex, oAlpha.HashCount[iIndex]);
    }
    return 0;
  }
}

The JIT

基础知识

与任何 VM 一样,CLR 需要一种方法将中间语言编译为本机代码。 编译在 CLR 中运行的程序时,编译器会将源从高级语言向下转换为 MSIL (Microsoft 中间语言) 和元数据的组合。 它们将合并到 PE 文件中,然后可在任何支持 CLR 的计算机上执行该文件。 运行此可执行文件时,JIT 开始将 IL 编译为本机代码,并在实际计算机上执行该代码。 这是基于每个方法完成的,因此 JITing 的延迟仅与要运行的代码所需的时间一样长。

JIT 速度相当快,可生成非常好的代码。 (会执行一些优化,下面讨论了每个) 的一些说明。 请记住,大多数这些优化都施加了限制,以确保 JIT 不会花费太多时间。

  • 常量折叠 - 在编译时计算常量值。

    以前 之后
    x = 5 + 7 x = 12
  • 常量和复制传播 - 向后替换以释放先前的变量。

    以前 之后
    x = a x = a
    y = x y = a
    z = 3 + y z = 3 + a
  • 方法内联 - 将 args 替换为在调用时传递的值,并消除调用。 然后,可以执行许多其他优化来切断死代码。 出于速度原因,当前 JIT 在内联方面有几个边界。 例如,只有较小的方法内联 (IL 大小小于 32) ,并且流控制分析相当基元。

    以前 之后
    ...

    x=foo(4, true);

    ...

    }

    foo(int a, bool b){

    if(b){

    return a + 5;

    } else {

    return 2a + bar();

    }

    ...

    x = 9

    ...

    }

    foo(int a, bool b){

    if(b){

    return a + 5;

    } else {

    return 2a + bar();

    }

  • 代码提升和控制器 - 如果代码在外部重复,请从内部循环中删除代码。 下面的“before”示例实际上是在 IL 级别生成的,因为必须检查所有数组索引。

    以前 之后
    for(i=0; i< a.length;i++){

    if(i < a.length()){

    a[i] = null

    } else {

    raise IndexOutOfBounds;

    }

    }

    for(int i=0; i<a.length; i++){

    a[i] = null;

    }

  • 循环展开 - 可以消除递增计数器和执行测试的开销,并且可以重复循环代码。 对于极其紧密的循环,这会导致性能获胜。

    以前 之后
    for(i=0; i< 3; i++){

    print("flaming monkeys!");

    }

    print("flaming monkeys!");

    print("flaming monkeys!");

    print("flaming monkeys!");

  • 常见 SubExpression 消除 - 如果活动变量仍包含正在重新计算的信息,请改用它。

    以前 之后
    x = 4 + y

    z = 4 + y

    x = 4 + y

    z = x

  • 注册 - 此处提供代码示例没有用,因此必须提供说明。 此优化可以花时间查看局部变量和 temps 在函数中的使用方式,并尝试尽可能高效地处理寄存器分配。 这是一项极其昂贵的优化,当前 CLR JIT 最多只考虑 64 个局部变量进行注册。 未考虑的变量放置在堆栈帧中。 这是 JITing 限制的一个经典示例:虽然这在 99% 的时间内是正常的,但具有 100 多个局部变量的高度不寻常的函数将更好地使用传统的耗时预编译进行优化。

  • 杂项 - 执行了其他简单的优化,但上面的列表是一个很好的示例。 JIT 还会传递死代码和其他窥视优化。

代码何时进行 JITed?

下面是代码执行时通过的路径:

  1. 加载程序,并使用引用 IL 的指针初始化函数表。
  2. Main 方法将 JITed 转换为本机代码,然后运行该代码。 对函数的调用通过 表编译为间接函数调用。
  3. 调用其他方法时,运行时会查看表,以查看它是否指向 JITed 代码。
    1. 如果它已 (它可能是从另一个调用站点调用的,或者已预编译) ,则控制流将继续。
    2. 否则,方法为 JITed,表将更新。
  4. 调用它们时,越来越多的方法被编译为本机代码,而表中的更多条目指向 x86 指令的不断增长的池。
  5. 当程序运行时,在编译所有内容之前,JIT 将越来越少地调用。
  6. 在调用某个方法之前,该方法不会被 JITed,然后在程序执行期间再也不会 JITed。 只需为使用的内容付费

误区:JITed 程序执行速度慢于预编译程序

这种情况很少见。 与从磁盘读取几个页面所用的时间相比,与 JITing 几个方法相关的开销很小,并且方法仅在需要时才进行 JITed。 在 JIT 中花费的时间非常小,几乎永远不会明显,并且一旦某个方法被 JITed,就再也不会产生该方法的成本。 我会在预编译代码部分对此进行详细介绍。

如上所述,版本 1 (v1) JIT 执行编译器执行的大部分优化,并且随着添加了更高级的优化,在下一个版本 (vNext) 中只会更快。 更重要的是,JIT 可以执行常规编译器无法执行的一些优化,例如特定于 CPU 的优化和缓存优化。

JIT-Only优化

由于 JIT 是在运行时激活的,因此编译器无法察觉到的很多信息。 这样,它就可以执行几个仅在运行时可用的优化:

  • 特定于处理器的优化 — 在运行时,JIT 知道它是否可以使用 SSE 或 3DNow 指令。 可执行文件将专门针对 P4、Athlon 或任何未来的处理器系列进行编译。 部署一次,相同的代码将与 JIT 和用户的计算机一起改进。
  • 优化间接性级别,因为函数和对象位置在运行时可用。
  • JIT 可以跨程序集执行优化,提供使用静态库编译程序时获得的许多好处,但保持使用动态库的灵活性和占用空间较小。
  • 更频繁地调用内联函数 ,因为它知道运行时的控制流。 这些优化可以大幅提升速度,并且 vNext 中有很大的进一步改进空间。

这些运行时改进以少量的一次性启动成本为代价,可以抵消 JIT 花费的时间。

使用 ngen.exe) 预编译代码 (

对于应用程序供应商来说,在安装过程中预编译代码是一个有吸引力的选择。 Microsoft 确实以 的形式 ngen.exe提供此选项,这将允许你对整个程序运行一次普通 JIT 编译器,并保存结果。 由于在预编译期间无法执行仅运行时优化,因此生成的代码通常不如正常 JIT 生成的代码好。 但是,无需即时 JIT 方法,启动成本要低得多,一些程序的启动速度会明显加快。 将来,ngen.exe可能不只是运行同一个运行时 JIT:具有比运行时更高边界的更积极的优化、向开发人员 (优化代码打包到 VM 页的方式) ,以及可在预编译期间利用时间的更复杂、更耗时的优化。

在两种情况下,缩短启动时间会有所帮助,对于其他所有情况,它与常规 JITing 可以做到的仅限运行时间优化不相竞争。 第一种情况是在程序早期调用大量方法。 必须预先 JIT 很多方法,从而导致不可接受的加载时间。 对于大多数人来说,情况并非如此,但如果预吉廷会影响你,则预吉廷可能有意义。 对于大型共享库,预编译也有意义,因为你更频繁地支付加载这些库的费用。 Microsoft 预编译 CLR 的框架,因为大多数应用程序都将使用它们。

使用ngen.exe 可以轻松查看预编译是否适合你,因此建议试用一下。但是,大多数情况下,最好使用正常的 JIT 并利用运行时优化。 他们有巨大的回报,在大多数情况下,将抵消一次性启动成本。

速度优化

对于程序员来说,实际上只有两件事值得注意。 首先,JIT 非常智能。 不要试图想出编译器。 按照通常的方式编写代码。 例如,假设有以下代码:

...

for(int i = 0; i < myArray.length; i++){

...

}

...

...

int l = myArray.length;

for(int i = 0; i < l; i++){

...

}

...

一些程序员认为,他们可以通过将长度计算移出并将其保存到温度来提升速度,如右侧的示例所示。

事实是,这样的优化近 10 年来一直没有帮助:新式编译器更有能力为你执行此优化。 事实上,有时这样的事情实际上会损害性能。 在上面的示例中,编译器可能会检查来查看 myArray 的长度为常量,并在 for 循环的比较中插入一个常量。 但右侧的代码可能会诱使编译器认为此值必须存储在寄存器中,因为 l 在整个循环中都是实时的。 最后一行是:编写最易读且最有意义的代码。 尝试超越编译器是无济于事的,有时它可能会造成伤害。

要谈论的第二件事是尾部调用。 目前,C# 和 Microsoft® Visual Basic® 编译器无法指定应使用结尾调用。 如果确实需要此功能,一个选项是在反汇编器中打开 PE 文件,并改用 MSIL .tail 指令。 这不是一个优雅的解决方案,但尾部调用在 C# 和 Visual Basic 中并不像在 Scheme 或 ML 等语言中那样有用。 人员编写真正利用结尾调用的语言的编译器时,应确保使用此指令。 大多数人的现实情况是,即使手动调整 IL 以使用尾部调用,也不会提供巨大的速度优势。 出于安全原因,有时运行时实际上会将这些更改回常规调用! 也许在将来的版本中,将投入更多精力来支持结尾调用,但目前性能提升不足以保证它,而且很少有程序员愿意利用它。

AppDomain

基础知识

进程间通信越来越普遍。 出于稳定性和安全原因,OS 将应用程序保留在单独的地址空间中。 一个简单的示例是在 NT 中执行所有 16 位应用程序的方式:如果在单独的进程中运行,则一个应用程序不会干扰另一个应用程序的执行。 此处的问题是上下文切换和在进程之间打开连接的成本。 此操作非常昂贵,并且对性能造成很大损害。 在通常托管多个 Web 应用程序的服务器应用程序中,这是性能和可伸缩性的主要消耗。

CLR 引入了 AppDomain 的概念,它类似于进程,因为它是应用程序的自包含空间。 但是,AppDomains 不限于每个进程一个。 由于托管代码提供了类型安全性,因此可以在同一进程中运行两个完全不相关的 AppDomain。 对于通常在进程间通信开销上花费大量执行时间的情况,此处的性能提升是巨大的:程序集之间的 IPC 比 NT 中的进程之间的 IPC 快 5 倍。 通过大幅降低此成本,可以在程序设计期间获得速度提升和新的选项:现在,在成本过高的情况下,使用单独的流程是有意义的。 在同一进程中以与以前相同的安全性运行多个程序的能力对可伸缩性和安全性有着巨大的影响。

OS 中不存在对 AppDomains 的支持。 AppDomain 由 CLR 主机处理,例如 ASP.NET、shell 可执行文件或 Microsoft Internet Explorer 中存在的主机。 还可以编写自己的 。 每个主机指定一个 默认域,该域在应用程序首次启动时加载,仅在进程结束时关闭。 将其他程序集加载到进程中时,可以指定将它们加载到特定的 AppDomain 中,并为每个程序集设置不同的安全策略。 Microsoft .NET Framework SDK 文档中对此进行了更详细的介绍。

速度优化

若要有效地使用 AppDomains,需要考虑要编写的应用程序类型,以及需要执行哪些类型的工作。 作为一个很好的经验法则,当应用程序符合以下一些特征时,AppDomains 最有效:

  • 它经常生成自身的新副本。
  • 它与其他应用程序一起使用,以处理 Web 服务器中的数据库查询 (信息,例如) 。
  • 它花费大量时间在 IPC 中使用专用于应用程序的程序。
  • 它会打开和关闭其他程序。

在复杂的 ASP.NET 应用程序中可以看到 AppDomains 有帮助的情况的示例。 假设你想要在不同的 vRoot 之间强制隔离:在本机空间中,需要将每个 vRoot 放在单独的进程中。 这相当昂贵,在它们之间切换上下文会产生很大的开销。 在托管世界中,每个 vRoot 可以是单独的 AppDomain。 这可以保留所需的隔离,同时大幅减少开销。

仅当应用程序足够复杂,需要与其他进程或其自身的其他实例密切合作时,才应使用 AppDomains。 虽然 iter-AppDomain 通信比进程间通信快得多,但启动和关闭 AppDomain 的成本实际上可能更高。 由于错误的原因使用 AppDomains 最终可能会损害性能,因此请确保在正确的情况下使用它们。 请注意,只能将托管代码加载到 AppDomain 中,因为无法保证非托管代码的安全。

在多个 AppDomain 之间共享的程序集必须针对每个域进行 JITed,以便保留域之间的隔离。 这会导致大量重复的代码创建和内存浪费。 请考虑使用某种 XML 服务响应请求的应用程序的情况。 如果某些请求必须彼此隔离,则需要将它们路由到不同的 AppDomain。 此处的问题是,每个 AppDomain 现在都需要相同的 XML 库,并且同一程序集将多次加载。

解决此问题的一种方法是将程序集声明为 “非特定域”,这意味着不允许直接引用,并且通过间接执行隔离。 这可以节省时间,因为程序集只有 JITed 一次。 它还可节省内存,因为不会复制任何内容。 遗憾的是,由于所需的间接性,性能受到影响。 当内存是考虑因素或浪费太多时间的 JITing 代码时,将程序集声明为非特定域的程序集会导致性能获胜。 此类方案在多个域共享的大型程序集中很常见。

安全性

基础知识

代码访问安全性是一项功能强大且非常有用。 它为用户提供半受信任的代码的安全执行,防止恶意软件和多种攻击,并允许对资源的受控、基于标识的访问。 在本机代码中,安全性极难提供,因为几乎没有类型安全性,程序员会处理内存。 在 CLR 中,运行时足够了解运行代码以添加强大的安全支持,这是大多数程序员的新增功能。

安全性会影响应用程序的速度和工作集大小。 而且,与大多数编程领域一样,开发人员使用安全性的方式可以极大地确定安全性对性能的影响。 安全系统在设计时考虑了性能,在大多数情况下,应用程序开发人员应该能够很好地运行。 但是,你可以执行一些操作来从安全系统中挤压最后一点的性能。

速度优化

执行安全检查通常需要堆栈演练,以确保调用当前方法的代码具有正确的权限。 运行时有一些优化,可帮助避免遍历整个堆栈,但程序员可以执行一些操作来提供帮助。 这让我们了解了命令性安全性与声明性安全性的概念:声明性安全性装饰具有各种权限的类型或其成员,而命令性安全性创建安全对象并对其执行操作。

  • 声明性安全性是 用于 AssertDenyPermitOnly 的最快方法。 这些操作通常需要堆栈演练来查找正确的调用帧,但如果显式声明这些修饰符,则可以避免这种情况。 如果以命令方式完成,则需求速度更快。
  • 使用非托管代码执行互操作时,可以使用 SuppressUnmanagedCodeSecurity 属性删除运行时安全检查。 这会将检查移动到链接时间,这要快得多。 请注意,请确保代码不会向其他代码公开任何安全漏洞,这可能会利用已删除检查不安全的代码。
  • 标识检查比代码检查更昂贵。 可以使用 LinkDemand 在链接时执行这些检查。

可通过两种方法优化安全性:

  • 在链接时间而不是运行时执行检查。
  • 使安全检查是声明性的,而不是强制性的。

你应该关注的第一件事是移动尽可能多的这些检查,以链接时间。 请记住,这可能会影响应用程序的安全性,因此请确保不要将检查移动到依赖于运行时状态的链接器中。 尽可能多地移动到链接时间后,应使用声明性或命令性安全性来优化运行时检查:选择最适合你使用的特定类型的检查。

远程处理

基础知识

.NET 中的远程处理技术通过网络扩展了丰富类型系统和 CLR 的功能。 使用 XML、SOAP 和 HTTP,可以远程调用过程并传递对象,就像它们托管在同一台计算机上一样。 可以将此视为 DCOM 或 CORBA 的 .NET 版本,因为它提供了其功能的超集。

这在服务器环境中特别有用,如果有多个服务器托管不同的服务,则所有服务器相互通信以无缝链接这些服务。 此外,可伸缩性也得到了改进,因为进程可以在物理上跨多台计算机进行拆分,而不会丢失功能。

速度优化

由于远程处理通常会在网络延迟方面产生损失,在 CLR 中应用始终具有的相同规则:尝试尽量减少发送的流量,并避免程序的其余部分等待远程调用返回。 下面是使用远程处理最大限度地提高性能时要遵循的一些好规则:

  • 进行区块而不是闲聊呼叫 - 查看是否可以减少必须远程拨打的呼叫数。 例如,假设使用 get () 和 set (# B3 方法为远程对象设置某些属性。 只需远程重新创建对象即可节省时间,并在创建时设置这些属性。 由于可以使用单个远程调用完成此操作,因此可以节省网络流量中浪费的时间。 有时,将对象移动到本地计算机,在那里设置属性,然后将其复制回可能有意义。 根据带宽和延迟,有时一种解决方案比另一种解决方案更有意义。
  • 平衡 CPU 负载与网络负载 - 有时发送一些要通过网络完成的任务是有意义的,有时最好自行完成工作。 如果浪费大量时间遍历网络,性能将受到影响。 如果 CPU 消耗过多,将无法响应其他请求。 在这两者之间找到良好的平衡对于使应用程序进行缩放至关重要。
  • 使用异步调用 - 在网络上进行呼叫时,请确保它是异步的,除非你确实需要其他调用。 否则,应用程序将停止,直到收到响应,这在用户界面或大容量服务器中是不可接受的。 .NET 附带的框架 SDK 中的“Samples\technologies\remoting\advanced\asyncdelegate”下提供了一个很好的示例。
  • 以最佳方式使用对象 - 可以指定为 SingleCall) (每个请求创建新对象,或者将同一对象用于 (单一实例) 的所有请求。 为所有请求使用单个对象当然不太占用资源,但你需要小心该对象的同步和配置(从请求到请求)。
  • 利用可插入通道和格式化程序 - 远程处理的一项强大功能是能够将任何通道或格式化程序插入应用程序。 例如,除非需要通过防火墙,否则没有理由使用 HTTP 通道。 插入 TCP 通道可以获得更好的性能。 请确保选择最适合你的频道或格式化程序。

ValueTypes

基础知识

对象提供的灵活性以较小的性能价格提供。 与堆栈托管对象相比,堆托管对象分配、访问和更新所需的时间更多。 例如,C++ 中的结构比对象更高效的原因。 当然,对象可以执行结构无法执行的操作,并且用途更广。

但有时你并不需要所有这些灵活性。 有时 你需要像结构一样简单的东西,而你不想支付性能成本。 CLR 使你能够指定所谓的 ValueType,在编译时将其视为结构。 ValueType 由堆栈管理,并提供结构的所有速度。 不出所料,它们还具有结构的灵活性有限, (没有继承,例如) 。 但对于只需要结构的实例,ValueTypes 可提供令人难以置信的速度提升。 有关 ValueTypes 和 CLR 类型系统的其余部分的详细信息,请参阅 MSDN 库。

速度优化

ValueTypes 仅在将其用作结构的情况下才有用。 如果需要将 ValueType 视为对象,运行时将处理装箱和取消装箱对象。 但是,这甚至比首先将其创建为对象更昂贵!

下面是一个简单测试示例,该测试比较创建大量对象和 ValueType 所需的时间:

using System;
using System.Collections;

namespace ConsoleApplication{
  public struct foo{
    public foo(double arg){ this.y = arg; }
    public double y;
  }
  public class bar{
    public bar(double arg){ this.y = arg; }
    public double y;
  }
class Class1{
  static void Main(string[] args){
    Console.WriteLine("starting struct loop....");
    int t1 = Environment.TickCount;
    for (int i = 0; i < 25000000; i++) {
      foo test1 = new foo(3.14);
      foo test2 = new foo(3.15);
       if (test1.y == test2.y) break; // prevent code from being 
       eliminated JIT
    }
    int t2 = Environment.TickCount;
    Console.WriteLine("struct loop: (" + (t2-t1) + "). starting object 
       loop....");
    t1 = Environment.TickCount;
    for (int i = 0; i < 25000000; i++) {
      bar test1 = new bar(3.14);
      bar test2 = new bar(3.15);
      if (test1.y == test2.y) break; // prevent code from being 
      eliminated JIT
    }
    t2 = Environment.TickCount;
    Console.WriteLine("object loop: (" + (t2-t1) + ")");
    }

亲自试一试吧。 时差以秒为单位。 现在,让我们修改程序,以便运行时必须装箱和取消装箱结构。 请注意,使用 ValueType 的速度优势已完全消失! 这里的寓意是,ValueType 仅在极少数情况下使用,当你不将它们用作对象时。 请务必注意这些情况,因为正确使用它们时,性能胜利通常非常大。

using System;
using System.Collections;

namespace ConsoleApplication{
  public struct foo{
    public foo(double arg){ this.y = arg; }
    public double y;
  }
  public class bar{
    public bar(double arg){ this.y = arg; }
    public double y;
  }
  class Class1{
    static void Main(string[] args){
      Hashtable boxed_table = new Hashtable(2);
      Hashtable object_table = new Hashtable(2);
      System.Console.WriteLine("starting struct loop...");
      for(int i = 0; i < 10000000; i++){
        boxed_table.Add(1, new foo(3.14)); 
        boxed_table.Add(2, new foo(3.15));
        boxed_table.Remove(1);
      }
      System.Console.WriteLine("struct loop complete. 
                                starting object loop...");
      for(int i = 0; i < 10000000; i++){
        object_table.Add(1, new bar(3.14)); 
        object_table.Add(2, new bar(3.15));
        object_table.Remove(1);
      }
      System.Console.WriteLine("All done");
    }
  }
}

Microsoft 大量使用 ValueTypes:框架中的所有基元都是 ValueTypes。 我的建议是,每当你觉得需要某个结构时,你都使用 ValueTypes。 只要你不装箱/拆箱,他们可以提供巨大的速度提升。

需要注意的一个极其重要的事项是,ValueTypes 在互操作方案中不需要封送处理。 由于封送处理是与本机代码互操作时最大的性能问题之一,因此使用 ValueTypes 作为本机函数的参数可能是你可以做的一项最大的性能调整。

其他资源

.NET Framework中性能的相关主题包括:

观看当前正在开发的未来文章,包括设计、体系结构和编码理念概述、托管世界中性能分析工具的演练,以及 .NET 与目前提供的其他企业应用程序的性能比较。

附录:托管服务器运行时

#include "mscoree.h"
#include "stdio.h"
#import "mscorlib.tlb" named_guids no_namespace raw_interfaces_only \
no_implementation exclude("IID_IObjectHandle", "IObjectHandle")

long main(){
  long retval = 0;
  LPWSTR pszFlavor = L"svr";

  // Bind to the Run time.
  ICorRuntimeHost *pHost = NULL;
  HRESULT hr = CorBindToRuntimeEx(NULL,
               pszFlavor, 
               NULL,
               CLSID_CorRuntimeHost, 
               IID_ICorRuntimeHost, 
               (void **)&pHost);

  if (SUCCEEDED(hr)){
    printf("Got ICorRuntimeHost\n");
      
    // Start the Run time (this also creates a default AppDomain)
    hr = pHost->Start();
    if(SUCCEEDED(hr)){
      printf("Started\n");
         
      // Get the Default AppDomain created when we called Start
      IUnknown *pUnk = NULL;
      hr = pHost->GetDefaultDomain(&pUnk);

      if(SUCCEEDED(hr)){
        printf("Got IUnknown\n");
            
        // Ask for the _AppDomain Interface
        _AppDomain *pDomain = NULL;
        hr = pUnk->QueryInterface(IID__AppDomain, (void**)&pDomain);
            
        if(SUCCEEDED(hr)){
          printf("Got _AppDomain\n");
               
          // Execute Assembly's entry point on this thread
          BSTR pszAssemblyName = SysAllocString(L"Managed.exe");
          hr = pDomain->ExecuteAssembly_2(pszAssemblyName, &retval);
          SysFreeString(pszAssemblyName);
               
          if (SUCCEEDED(hr)){
            printf("Execution completed\n");

            //Execution completed Successfully
            pDomain->Release();
            pUnk->Release();
            pHost->Stop();
            
            return retval;
          }
        }
        pDomain->Release();
        pUnk->Release();
      }
    }
    pHost->Release();
  }
  printf("Failure, HRESULT: %x\n", hr);
   
  // If we got here, there was an error, return the HRESULT
  return hr;
}

如果对本文有疑问或意见,请联系项目经理 Claudio Caldato,解决.NET Framework性能问题。