共用方式為


.NET Framework 中Run-Time技術的效能考慮

 

Emmanuel Schanzer
Microsoft Corporation

2001 年 8 月

總結: 本文包含受管理世界中各種技術的調查,以及其如何影響效能的技術說明。 瞭解垃圾收集、JIT、遠端、ValueTypes、安全性等工作。 (27 個列印頁面)

目錄

概觀
記憶體回收
執行緒集區
The JIT
AppDomain
安全性
遠端
ValueTypes
其他資源
附錄:裝載伺服器執行時間

概觀

.NET 執行時間引進數種進階技術,旨在提供安全性、輕鬆開發和效能。 身為開發人員,請務必瞭解每個技術,並在您的程式碼中有效地使用這些技術。 執行時間所提供的進階工具可讓您輕鬆地建置健全的應用程式,但讓該應用程式快速 (,且一律) 開發人員的責任。

本白皮書應該讓您更深入瞭解 .NET 中工作的技術,並協助您調整程式碼的速度。 注意:這不是規格表。 已經有許多穩固的技術資訊。 這裡的目標是提供對效能的強烈傾斜資訊,而且可能不會回答您擁有的每個技術問題。 如果您找不到您在這裡尋找的答案,建議您在 MSDN Online Library 中進一步查看。

我即將涵蓋下列技術,並提供其用途的高階概觀,以及其影響效能的原因。 然後,我將深入探討一些較低層級的實作詳細資料,並使用範例程式碼來說明如何加快每個技術的速度。

記憶體回收

基本概念

垃圾收集 (GC) 釋出程式設計人員不再使用之物件的記憶體,藉此釋放常見且難以偵錯的錯誤。 在 Managed 和機器碼中,物件存留期所遵循的一般路徑如下:

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

在機器碼中,您必須自行執行所有這些動作。 遺漏配置或清除階段可能會導致難以偵錯的完全無法預期行為,而忘記釋放物件可能會導致記憶體流失。 Common Language Runtime (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

在可以釋放物件之前,這兩個世界中都會採取相同的步驟。 在機器碼中,當您完成物件時,您必須記得釋放物件。 在 Managed 程式碼中,一旦物件無法連線,GC 就可以收集它。 當然,如果您的資源需要特別注意, (說,關閉通訊端) GC 可能需要正確關閉的協助。 您先前撰寫的程式碼在釋放資源之前仍適用清除資源,格式為 Dispose () Finalize () 方法。 我稍後會討論這兩者之間的差異。

如果您將指標保留在資源周圍,GC 就無法知道您未來是否想要使用它。 這表示您在機器碼中用來明確釋放物件的所有規則仍然適用,但大部分時候 GC 都會為您處理所有專案。 您不需要擔心記憶體管理一百分之一百的時間,您只需要擔心大約 5% 的時間。

CLR 垃圾收集行程是產生、標記和精簡的收集器。 它會遵循數個原則,以達到絕佳的效能。 首先,有一個概念是短期的物件通常較小且經常存取。 GC 會將配置圖形分割成 數個稱為世代的子圖形,讓其花費的時間越少越少收集越好*.* Gen 0,包含較年長且經常使用的物件。 這通常也是最小的,大約需要 10 毫秒才能收集。 由於 GC 可以在此集合期間忽略其他層代,因此可提供更高的效能。 G1 和 G2 適用于較大型、較舊的物件,而且較不常收集。 發生 G1 集合時,也會收集 G0。 G2 集合是完整的集合,也是 GC 周遊整個圖表的唯一時間。 它也會使用智慧型 CPU 快取,這可以針對其執行所在的特定處理器調整記憶體子系統。 這是原生配置中不容易提供的優化,可協助您的應用程式改善效能。

集合何時發生?

進行時間配置時,GC 會檢查是否需要集合。 它會查看集合的大小、剩餘的記憶體數量,以及每個世代的大小,然後使用啟發學習法來做出決策。 在集合發生之前,物件配置的速度通常會比 C 或 C++ 更快 (或更快) 。

集合發生時會發生什麼事?

讓我們逐步解說垃圾收集行程在收集期間所採取的步驟。 GC 會維護指向 GC 堆積的根目錄清單。 如果物件是即時的,則堆積中的位置有根。 堆積中的物件也可以彼此指向。 此指標圖表是 GC 必須搜尋才能釋出空間的內容。 事件的順序如下:

  1. Managed 堆積會將其所有配置空間保留在連續區塊中,而且當此區塊小於所要求的數量時,會呼叫 GC。

  2. GC 會遵循每個根目錄和後續的所有指標,並維護 無法 連線的物件清單。

  3. 無法從任何根目錄觸達的每個物件都會被視為可收集,並標示為收集。

    圖 1. 在集合之前:請注意,並非所有區塊都可從根目錄連線到!

  4. 從可觸達性圖形中移除物件可讓大部分的物件可收集。 不過,某些資源必須特別處理。 當您定義物件時,您可以選擇撰寫 Dispose () 方法或 Finalize () 方法 (或兩者) 。 我將會討論這兩者之間的差異,以及稍後使用時機。

  5. 集合的最後一個步驟是壓縮階段。 使用中的所有物件都會移至連續區塊,並更新所有指標和根目錄。

  6. 藉由壓縮即時物件並更新可用空間的開始位址,GC 會維護所有可用空間都是連續的。 如果有足夠的空間可設定物件,GC 會將控制權傳回給程式。 如果沒有,則會引發 OutOfMemoryException

    圖 2. 收集之後:已壓縮可連線的區塊。 更多可用空間!

如需記憶體管理的詳細資訊,請參閱 Microsoft Windows 程式設計應用程式 第 3 章:Jeffrey Richter (Microsoft Press,1999) 。

物件清除

有些物件需要特殊處理,才能傳回其資源。 這類資源的一些範例包括檔案、網路通訊端或資料庫連結。 只要釋放堆積上的記憶體並不夠,因為您希望這些資源正常關閉。 若要執行物件清除,您可以撰寫 Dispose () 方法、 Finalize () 方法或兩者。

Finalize () 方法:

  • 由 GC 呼叫
  • 不保證會依任何順序或可預測的時間呼叫
  • 呼叫之後,在下 一個 GC 之後釋放記憶體
  • 將所有子物件保持在上線狀態,直到下一個 GC 為止

Dispose () 方法:

  • 由程式設計人員呼叫
  • 已由程式設計人員排序和排程
  • 在方法完成時傳回資源

只保留受控資源的 Managed 物件不需要這些方法。 您的程式可能只會使用一些複雜的資源,而且您可能知道它們是什麼,以及何時需要這些資源。 如果您知道這兩件事,就沒有任何理由依賴完成項,因為您可以手動執行清除。 有數個您想要這樣做的原因,而且它們全都必須與 完成項佇列執行。

在 GC 中,當具有完成項的物件標示為可收集時,它和它所指向的任何物件都會放在特殊佇列中。 個別執行緒會逐步執行此佇列,呼叫佇列中每個專案的 Finalize () 方法。 程式設計人員無法控制此執行緒,或放置於佇列中的專案順序。 GC 可能會將控制權傳回程序,而不需要完成佇列中的任何物件。 這些物件可能會保留在記憶體中,長時間保留在佇列中。 對完成的呼叫會自動完成,而且不會對呼叫本身產生直接效能影響。 不過,最終處理的非決定性模型 絕對 會有其他間接結果:

  • 在您需要在特定時間釋出資源的案例中,您會失去使用完成項的控制。 假設您已開啟檔案,且基於安全性考慮需要關閉。 即使您將物件設定為 null,並立即強制 GC,檔案仍會保持開啟狀態,直到呼叫其 Finalize () 方法為止,而且您不知道何時可能發生此情況。
  • 需要以特定順序處置的 N 個物件可能無法正確處理。
  • 龐大的物件及其子系可能佔用太多記憶體,需要額外的集合並降低效能。 這些物件可能不會收集很長的時間。
  • 要完成的小型物件可能會有可隨時釋放之大型資源的指標。 這些物件將不會釋放,直到處理完成的物件、建立不必要的記憶體壓力並強制頻繁的集合為止。

圖 3 中的狀態圖說明物件在最終處理或處置方面可以採用的不同路徑。

圖 3. 物件可接受的處置和最終處理路徑

如您所見,最終處理會將數個步驟新增至物件的存留期。 如果您自行處置物件,則可以收集物件,並在下一個 GC 中傳回給您的記憶體。 需要進行最終處理時,您必須等到實際方法被呼叫為止。 由於您未獲得有關何時發生此情況的任何保證,因此您可以系結許多記憶體,並且會受到最終處理佇列的考慮。 如果您的物件連接到整個物件的樹狀結構,而且它們全都位於記憶體中,直到最終發生為止,這可能會非常有問題。

選擇要使用哪一個垃圾收集行程

CLR 有兩個不同的 DC:工作站 (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++ 中,這些作業不安全或非常有限。

調整速度

受控世界中程式的最大顧慮是 記憶體保留。 您在 Unmanaged 環境中發現的某些問題不是受控世界中的問題:記憶體流失和懸置指標不是這裡的問題。 相反地,程式設計人員必須小心讓資源在不再需要資源時保持連線。

效能最重要的啟發學習法也是最簡單的學習方法,可供用來撰寫機器碼的程式設計人員學習:追蹤要進行的配置,並在完成時釋出配置。 GC 無法得知您不會使用您建立的 20KB 字串,如果它是保留的物件一部分。 假設您已將這個物件隱藏在向量某處,而且您永遠不會想要再次使用該字串。 將欄位設定為 null 會讓 GC 稍後收集這 20KB,即使您仍然需要物件供其他用途使用。 如果您不再需要物件,請確定您不會保留其參考。 (就像在機器碼中一樣。) 針對較小的物件,這比較不發生問題。 在機器碼中熟悉記憶體管理的任何程式設計人員都不會有任何問題:所有相同的常見意義規則都適用。 您不一定需要有這些參數。

第二個重要的效能考慮會處理物件清除。 如先前所述,最終處理會對效能造成重大影響。 最常見的範例是 Unmanaged 資源的 Managed 處理常式:您需要實作某種清除方法,這是效能變成問題的地方。 如果您相依于最終設定,您可以自行瞭解我稍早列出的效能問題。 請記住其他事項是 GC 幾乎不會察覺原生世界中的記憶體壓力,因此您可能只要在 Managed 堆積中保留指標,就可以使用一組非受控資源。 單一指標不會佔用大量的記憶體,因此可能需要一段時間才能進行集合。 若要解決這些效能問題,同時在記憶體保留期間仍可安全播放,您應該挑選設計模式來處理需要特殊清除的所有物件。

程式設計人員在處理物件清除時有四個選項:

  1. 實作兩者

    這是物件清除的建議設計。 這是一個物件,其中包含一些非受控和受控資源混合。 例如 System.Windows.Forms.Control。 這具有非受控資源 (HWND) ,且可能管理的資源 (DataConnection 等) 。 如果您不確定何時使用 Unmanaged 資源,您可以在 中 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 () 唯一物件的影響在於程式設計人員不知道何時要收集物件,但使用足以要求特殊清除的資源複雜程度。 這種情況應該永遠不會發生在設計良好的專案中,如果您發現自己在專案中,您應該返回並找出發生錯誤的情況。

    範例:

    public class MyClass {
      ...
      ~MyClass() {
        ...
      }
    
  4. 實作兩者皆未實作

    這適用于僅指向無法處置或無法完成之其他 Managed 物件的 Managed 物件。

建議

處理記憶體管理的建議應該很熟悉:當您完成記憶體管理時釋放物件,並留意物件指標。 在物件清除方面,請針對具有 Unmanaged 資源的物件實作Finalize () Dispose () 方法。 這會在稍後防止非預期的行為,並強制執行良好的程式設計做法

這裡的缺點是您強制人員必須呼叫 Dispose () 。 此處沒有效能遺失,但有些人可能會覺得很難考慮處置其物件。 不過,我認為使用有意義的模型是值得的。 此外,這可強制人們更重視他們所配置的物件,因為它們無法盲目信任 GC 以一律處理這些物件。 對於來自 C 或 C++ 背景的程式設計人員而言,強制 呼叫 Dispose () 可能會很有説明,因為它是他們更熟悉的專案。

處置 () 應該支援保留 Unmanaged 資源的物件,該物件位於其下方物件樹狀結構中的任何位置;不過,Finalize () 只需要放在特別保留這些資源的物件上,例如 OS 控制碼或 Unmanaged 記憶體配置。 除了支援Dispose () 之外,建議將小型 Managed 物件建立為「包裝函式」,以實作Finalize () , ,父物件的Dispose () 。 因為父物件沒有完成項,所以不論 呼叫 Dispose () ,物件的整個樹狀結構都不會存留集合。

完成項的良好經驗法則只是在 需要 最終處理的最基本物件上使用它們。 假設我有包含資料庫連線的大型受控資源:我可以讓連接本身完成,但讓其餘物件可處置。 如此一來,我可以呼叫 Dispose () 並立即釋放物件的 Managed 部分,而不需要等待連接完成。 請記住:當您必須時,請只使用Finalize ()

注意 C 和 C++ 程式設計人員:C# 中的解構函式語意會建立 完成項,而不是處置方法!

執行緒集區

基本概念

CLR 的執行緒集區在許多方面都類似于 NT 執行緒集區,而且幾乎不需要對程式設計人員有新的瞭解。 它有等候執行緒,可處理其他執行緒的區塊,並在需要傳回時通知它們,並釋放它們來執行其他工作。 它可能會繁衍新的執行緒,並封鎖其他人在執行時間優化 CPU 使用率,以確保完成最多有用的工作。 它也會線上程完成時回收執行緒,再次啟動執行緒,而不會造成終止和繁衍新執行緒的額外負荷。 這是透過手動方式處理執行緒的大幅效能提升,但不是全部擷取。 瞭解何時使用執行緒集區是微調執行緒應用程式時不可或缺的。

您從 NT 執行緒集區知道的內容:

  • 執行緒集區會處理執行緒建立和清除。
  • 它僅 (NT 平臺提供 I/O 執行緒的完成埠) 。
  • 回呼可以系結至檔案或其他系統資源。
  • 計時器和等候 API 可供使用。
  • 執行緒集區會使用啟發學習法,例如自上次插入後延遲、目前線程數目和佇列大小等啟發學習法來決定應該作用中的執行緒數目。
  • 來自共用佇列的執行緒摘要。

.NET 中有何不同:

  • 它知道 Managed 程式碼中封鎖的執行緒 (例如,因為垃圾收集、Managed 等候) ,而且可以據以調整其執行緒插入邏輯。
  • 無法保證個別執行緒的服務。

何時自行處理執行緒

有效地使用執行緒集區與瞭解執行緒所需的內容緊密連結。 如果您需要服務保證,您必須自行管理。 在大部分情況下,使用集區可提供最佳效能。 如果您有嚴格的限制,而且需要嚴格控制執行緒,則仍然使用原生執行緒可能更合理,因此請小心自行處理 Managed 執行緒。 如果您決定自行撰寫 Managed 程式碼並處理執行緒,請確定您不會在每個連線上繁衍執行緒:這只會降低效能。 根據經驗法則,您應該只在非常特定的案例中,選擇自行在受控世界中處理執行緒,其中很少完成的大型耗時工作。 其中一個範例可能是在背景填滿大型快取,或將大型檔案寫出至磁片。

調整速度

執行緒集區會設定應使用中線程數目的限制,如果其中有許多執行緒都封鎖,集區將會耗盡。 在理想情況下,您應該針對短期的非封鎖執行緒使用執行緒集區。 在伺服器應用程式中,您想要快速且有效率地回答每個要求。 如果您針對每個要求啟動新的執行緒,則會處理許多額外負荷。 解決方案是回收執行緒,並在完成時負責清除並傳回每個執行緒的狀態。 這些案例是執行緒集區是主要的效能和設計勝出,您應該在其中充分利用技術。 執行緒集區會為您處理狀態清除,並確定在指定時間使用的最佳執行緒數目。 在其他情況下,自行處理執行緒可能更合理。

雖然 CLR 可以使用類型安全性來確保 AppDomains 可以共用相同的進程,但執行緒不會有這類保證。 程式設計人員負責撰寫行為良好的執行緒,而且原生程式碼的所有知識仍然適用。

以下是利用執行緒集區之簡單應用程式的範例。 它會建立一堆背景工作執行緒,然後讓它們執行簡單的工作,再將其關閉。 我已取出一些錯誤檢查,但這是可以在 [Samples\Threading\Threadpool] 底下的 Framework SDK 資料夾中找到的相同程式碼。 在此範例中,我們有一些程式碼可建立簡單的工作專案,並使用 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 Intermediate Language) 和中繼資料的組合。 這些檔案會合並到 PE 檔案中,然後可在任何支援 CLR 的電腦上執行。 當您執行此可執行檔時,JIT 會開始將 IL 編譯為機器碼,並在實際電腦上執行該程式碼。 這是以每個方法為基礎完成,因此只要您想要執行的程式碼需要 JITing 的延遲。

JIT 相當快,而且會產生非常良好的程式碼。 以下是其執行 (的一些優化,以及每個) 的一些說明。 請記住,這些優化大部分都有限制,以確保 JIT 不會花費太多時間。

  • 常數折迭— 在編譯時期計算常數值。

    之前 After
    x = 5 + 7 x = 12
  • 常數和複製傳播— 以回溯取代來釋放稍早的變數。

    之前 After
    x = a x = a
    y = x y = a
    z = 3 + y z = 3 + a
  • 方法內嵌— 以在呼叫時傳遞的值取代 args,並消除呼叫。 接著可以執行許多其他優化,以剪除不正確程式碼。 基於速度考慮,目前的 JIT 有數個界限可內嵌。 例如,只有小型方法會內嵌 (IL 大小小於 32) ,而流程式控制制分析相當基本。

    之前 After
    ...

    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 層級產生的專案,因為必須檢查所有陣列索引。

    之前 After
    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;

    }

  • 迴圈取消註冊— 可以移除遞增計數器和執行測試的額外負荷,而且可以重複迴圈的程式碼。 針對非常緊密的迴圈,這會導致效能勝出。

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

    print("flaming monkeys!");

    }

    print("flaming monkeys!");

    print("flaming monkeys!");

    print("flaming monkeys!");

  • 一般 SubExpression 刪除- 如果即時變數仍然包含要重新計算的資訊,請改用它。

    之前 After
    x = 4 + y

    z = 4 + y

    x = 4 + y

    z = x

  • 登錄- 此處提供程式碼範例並不實用,因此說明必須足夠。 此優化可以花時間查看函式中如何使用區域變數和暫存,並嘗試盡可能有效率地處理暫存器指派。 這可以是極耗費資源的優化,而目前的 CLR JIT 只會考慮最多 64 個區域變數進行註冊。 未考慮的變數會放在堆疊框架中。 這是 JITing 限制的傳統範例:雖然這是 99% 的時間,但具有 100 個以上區域變數的高異常函式會使用傳統且耗時的預先編譯來優化。

  • Misc - 執行其他簡單的優化,但上述清單是不錯的範例。 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 許多方法,導致無法接受的載入時間。 對大部分的人來說,這並不一定是這種情況,但如果影響您,則預先 JITing 可能很合理。 在大型共用程式庫的情況下,先行編譯也相當合理,因為您需支付載入這些程式庫的成本。 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® 編譯器無法讓您指定應該使用 tail 呼叫。 如果您真的需要這項功能,其中一個選項是在反組譯程式中開啟 PE 檔案,並改用 MSIL .tail 指令。 這不是簡潔的解決方案,但在 C# 和 Visual Basic 中,尾呼叫在 C# 和 Visual Basic 中並不如配置或 ML 等語言。 人員撰寫真正利用尾呼叫之語言的編譯器,請務必使用此指令。 大部分人的實際情況是,甚至手動調整 IL 以使用尾呼叫並不會提供極大的速度優勢。 有時候執行時間會實際將這些變更回一般呼叫,基於安全性考慮! 或許在未來版本中,將投入更多心力來支援尾呼叫,但目前效能提升不足以保證,而且很少程式設計人員想要利用它。

AppDomain

基本概念

處理序間通訊變得越來越常見。 基於穩定性和安全性考慮,OS 會將應用程式保留在不同的位址空間中。 簡單範例是在 NT 中執行所有 16 位應用程式的方式:如果在個別進程中執行,則一個應用程式無法干擾另一個應用程式的執行。 這裡的問題是內容切換的成本,以及開啟進程之間的連線。 這項作業成本很高,而且會大幅降低效能。 在經常裝載數個 Web 應用程式的伺服器應用程式中,這是效能和延展性的主要清空。

CLR 引進了 AppDomain 的概念,其類似于程式,也就是應用程式的獨立空間。 不過,AppDomain 不限於每個進程一個。 由於 Managed 程式碼所提供的型別安全,因此可以在相同進程中執行兩個完全不相關的 AppDomain。 此處的效能提升對於通常花費許多執行時間在處理序間通訊額外負荷的情況而言相當龐大:元件之間的 IPC 速度比 NT 中的進程快五倍。 藉由大幅降低此成本,您會在程式設計期間同時獲得速度提升和新的選項:現在使用不同程式可能太昂貴之前就很合理。 在與之前相同的進程中執行多個程式的能力,對於延展性和安全性具有極大的影響。

OS 中沒有 AppDomains 的支援。 AppDomain 是由 CLR 主機處理,例如存在於 ASP.NET、殼層可執行檔或 Microsoft Internet Explorer 中的主機。 您也可以自行撰寫。 每個主機都會指定 預設網域,此網域會在應用程式第一次啟動時載入,而且只有在進程結束時才會關閉。 當您將其他元件載入進程時,您可以指定它們已載入特定的 AppDomain,並為每個元件設定不同的安全性原則。 Microsoft .NET Framework SDK 檔會更詳細地說明這一點。

調整速度

若要有效地使用 AppDomains,您必須思考您要撰寫的應用程式類型,以及它需要執行的工作類型。 作為一個很好的經驗法則,當您的應用程式符合下列一些特性時,AppDomains 最有效:

  • 它通常會繁衍新的本身複本。
  • 它與其他應用程式搭配使用,以處理網頁伺服器內 (資料庫查詢的資訊,例如) 。
  • 它會在 IPC 中花費許多時間,這些程式專門與您的應用程式搭配使用。
  • 它會開啟並關閉其他程式。

在複雜的 ASP.NET 應用程式中,AppDomains 很有用的情況範例。 假設您想要在不同的 vRoot 之間強制執行隔離:在原生空間中,您必須將每個 vRoot 放在個別的進程中。 這相當昂貴,而且它們之間的內容切換是大量的額外負荷。 在受控世界中,每個 vRoot 都可以是個別的 AppDomain。 這會保留所需的隔離,同時大幅減少額外負荷。

AppDomains 是您只有在應用程式夠複雜而需要與其他進程或本身的其他實例密切合作時,才應該使用。 雖然 iter-AppDomain 通訊的速度遠高於處理序間通訊,但啟動和關閉 AppDomain 的成本實際上會比較昂貴。 AppDomains 最後可能會因為錯誤原因而造成效能降低,因此請確定您在正確的情況下使用它們。 請注意,只有 Managed 程式碼可以載入 AppDomain,因為無法保證 Unmanaged 程式碼安全。

在多個 AppDomain 之間共用的元件必須針對每個網域進行 JITed,才能在網域之間保留隔離。 這會導致許多重複的程式碼建立和浪費的記憶體。 請考慮使用某種 XML 服務回答要求的應用程式案例。 如果某些要求必須彼此隔離,您必須將它們路由傳送至不同的 AppDomains。 此處的問題在於,每個 AppDomain 現在都需要相同的 XML 程式庫,而且會多次載入相同的元件。

其中一個解決方法是宣告元件為 網域中性,這表示不允許任何直接參考,而且透過間接取值強制執行隔離。 這可節省時間,因為元件只有 JITed 一次。 它也會節省記憶體,因為不會重複任何專案。 不幸的是,由於需要間接取值,所以效能達到。 宣告元件為網域中性,會導致記憶體是考慮的效能勝出,或是浪費太多時間的 JITing 程式碼時。 這類案例在數個網域共用的大型元件案例中很常見。

安全性

基本概念

程式碼存取安全性是功能強大的非常實用功能。 它可讓使用者安全地執行半信任的程式碼、防止惡意軟體和數種攻擊,並允許受控的身分識別型存取資源。 在機器碼中,安全性非常難以提供,因為型別安全性很少,而且程式設計人員會處理記憶體。 在 CLR 中,執行時間已足夠瞭解執行程式碼以新增強式安全性支援,這是大部分程式設計人員的新功能。

安全性會影響應用程式的速度和工作集大小。 而且,如同大部分的程式設計領域,開發人員如何使用安全性,可以大幅判斷其對效能的影響。 安全性系統的設計以效能為考慮,在大部分情況下,應用程式開發人員應該能以少量或完全不考慮的方式執行。 不過,您可以執行數件事來從安全性系統擷取最後一個效能。

調整速度

執行安全性檢查通常需要堆疊逐步解說,以確保呼叫目前方法的程式碼具有正確的許可權。 執行時間有數個優化,可協助避免逐步執行整個堆疊,但程式設計人員可以執行數件事來協助。 這可讓我們瞭解命令式安全性與宣告式安全性的概念:宣告式安全性裝飾具有各種許可權的類型或其成員,而命令式安全性會建立安全性物件,並對其執行作業。

  • 宣告式安全性是 判斷提示拒絕PermitOnly的最快速方式。 這些作業通常需要堆疊逐步解說來找出正確的呼叫框架,但如果您明確宣告這些修飾詞,則可以避免此情況。 如果以命令方式完成,需求會更快。
  • 使用 Unmanaged 程式碼執行 Interop 時,您可以使用 SuppressUnmanagedCodeSecurity 屬性來移除執行時間安全性檢查。 這會將簽入連結時間移動,速度會更快。 請注意,請確定程式碼不會對其他程式碼公開任何安全性漏洞,這可能會利用移除的簽入不安全的程式碼。
  • 身分識別檢查比程式碼檢查更昂貴。 您可以改用 LinkDemand 在連結時間執行這些檢查。

有兩種方式可將安全性優化:

  • 在連結時間執行檢查,而不是執行時間。
  • 進行宣告式安全性檢查,而不是命令式檢查。

您應該專注于的第一件事是盡可能移動其中許多檢查,以連結時間。 請記住,這可能會影響應用程式的安全性,因此請確定您不會將檢查移至相依于執行時間狀態的連結器。 一旦您盡可能移至連結時間,您應該使用宣告式或命令式安全性來優化執行時間檢查:選擇最適合您使用的特定檢查類型。

遠端

基本概念

.NET 中的遠端技術可擴充豐富型別系統和透過網路 CLR 的功能。 使用 XML、SOAP 和 HTTP,您可以呼叫程式和從遠端傳遞物件,就像它們裝載在同一部電腦上一樣。 您可以將這視為 DCOM 或 CORBA 的 .NET 版本,因為它提供其功能的超集合。

這在伺服器環境中特別有用,當您有數部裝載不同服務的伺服器時,所有伺服器都會彼此交談,以順暢地連結這些服務。 延展性也會改善,因為進程可以實際分散到多部電腦,而不會遺失功能。

調整速度

由於遠端處理通常會在網路延遲方面產生負面影響,因此一律會在 CLR 中套用相同的規則:嘗試將您傳送的流量降到最低,並避免讓其餘的程式等候遠端呼叫傳回。 以下是使用遠端功能將效能最大化時的一些良好規則:

  • 進行區塊化而不是聊天通話—查看您是否可以減少您必須從遠端進行的通話數目。 例如,假設您使用 get () 和 set () 方法來 設定 遠端物件的一些屬性。 只要從遠端重新建立物件,就能節省時間,並在建立時設定這些屬性。 由於這可以使用單一遠端呼叫來完成,因此您將節省網路流量浪費的時間。 有時候,將物件移至本機電腦、將屬性設定在該處,然後複製回它可能很合理。 視頻寬和延遲而定,有時候一個解決方案會比另一個解決方案更合理。
  • 平衡 CPU 負載與網路負載-有時傳送一些透過網路完成的作業很合理,而其他時候最好自行執行工作。 如果您浪費大量時間周遊網路,您的效能將會受到影響。 如果您使用太多 CPU,您將無法回答其他要求。 在這兩者之間尋找良好的平衡,是讓您的應用程式進行調整的必要條件。
  • 使用非同步呼叫— 當您透過網路進行呼叫時,請確定它是非同步,除非您真的需要其他專案。 否則您的應用程式會停止,直到收到回應為止,而且在使用者介面或大量伺服器中可能無法接受。 在隨附于 .NET 的 Framework SDK 中,有一個良好的範例可供查看,其位於 「Samples\technologies\remoting\advanced\asyncdelegate」下。
  • 以最佳方式使用物件— 您可以指定針對 SingleCall) (的每個要求建立新的物件,或針對 Singleton) 的所有 (要求使用相同的物件。 針對所有要求擁有單一物件當然會耗用較少的資源,但您必須注意物件的同步處理和要求設定。
  • 利用插入式通道和格式器—遠端功能的強大功能是能夠將任何通道或格式器插入您的應用程式。 例如,除非您需要通過防火牆,否則不需要使用 HTTP 通道。 插入 TCP 通道可讓您獲得更好的效能。 請確定您選擇最適合的通道或格式器。

ValueTypes

基本概念

物件所提供的彈性會以較小的效能價格提供。 堆積管理的物件需要比堆疊管理物件更多的時間來配置、存取和更新。 例如,C++ 中的結構比物件更有效率的原因。 當然,物件可以執行結構無法執行的動作,而且更靈活。

但有時候您不需要所有彈性。 有時候 您想要像 結構一樣簡單,而且您不想要支付效能成本。 CLR 可讓您指定稱為 ValueType的內容,並在編譯時期將它視為結構。 ValueTypes 是由堆疊管理,並提供結構的所有速度。 如預期般,它們也隨附結構 (沒有繼承的有限彈性,例如) 。 但是,對於您只需要結構的實例,ValueTypes 可提供絕佳的速度提升。 MSDN Library 提供 有關 ValueTypes和 CLR 類型系統其餘部分的詳細資訊。

調整速度

ValueTypes 只有在您使用它們做為結構的情況下才有用。 如果您需要將 ValueType 視為物件,執行時間會為您處理 Boxing 和 Unboxing 物件。 不過,這比第一個位置建立為物件更昂貴!

以下是簡單的測試範例,其會比較建立大量物件和 ValueTypes 所需的時間:

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。 只要您沒有 box/unbox,他們就能提供極大的速度提升。

請務必注意的是,ValueTypes 在 Interop 案例中不需要封送處理。 由於封送處理是與原生程式碼互通時最大的效能點擊之一,因此使用 ValueTypes 做為原生函式的引數可能是您可以執行的最大效能調整。

其他資源

.NET Framework效能的相關主題包括:

觀看目前正在開發的未來文章,包括設計概觀、架構和程式碼撰寫原理、受控世界中效能分析工具的逐步解說,以及目前可用的其他企業應用程式的效能比較。

附錄:裝載伺服器執行時間

#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效能問題。