了解 .NET/C# 程式集的載入時機,以便優化程式啟動性能

  • 2020 年 2 月 10 日
  • 筆記

了解 .NET/C# 程式集的載入時機,以便優化程式啟動性能

2018-11-11 11:06

林德熙在 C# 程式集數量對軟體啟動性能的影響 一文中說到程式集數量對程式啟動性能的影響。在那篇文章中,我們得出結論,想同類數量的情況下,程式集的數量越多,程式啟動越慢。

額外的,不同的程式碼編寫方式對程式集的載入性能也有影響。本文將介紹 .NET 中程式集的載入時機,了解這個時機能夠對啟動期間程式集的載入性能帶來幫助。


程式集載入方式對性能的影響

為了直觀地說明程式集載入方式對性能的影響,我們先來看一段程式碼:

using System;  using System.Threading.Tasks;    namespace Walterlv.Demo  {      public static class Program      {          [STAThread]          private static int Main(string[] args)          {              var logger = new StartupLogger();              var startupManagerTask = Task.Run(() =>              {                  var startup = new StartupManager(logger).ConfigAssemblies(                      new Foo(),                      new Bar(),                      new Xxx(),                      new Yyy(),                      new Zzz(),                      new Www());                  startup.Run();                  return startup;              });                var app = new App(startupManagerTask);              app.InitializeComponent();              app.Run();                return 0;          }      }  }

在這段程式碼中,FooBarXxxYyyZzzWww 分別在不同的程式集中,我們姑且認為程式集名稱是 FooAssembly、BarAssembly、XxxAssembly、YyyAssembly、ZzzAssembly、WwwAssembly。

現在,我們統計 Main 函數開始第一句話到 Run 函數開始執行時的時間:

統計

Milestone

Time

第一次

——————————–

——-:

第一次

Main Method Start

107

第一次

Run

344

第二次

Main Method Start

106

第二次

Run

276

第三次

Main Method Start

89

第三次

Run

224

在三次統計中,我們可以看到三次平均時長 180 ms。如果觀察沒一句執行時的 Module,可以看到 Main 函數開始時,這些程式集都未載入,而 Run 函數執行時,這些程式集都已載入。

事實上,如果你把斷點放在 Task.Run 中 lambda 表達式的第一個括弧處,你會發現那一句時這些程式集就已經載入了,不用等到後面程式碼的執行。

作為對比,我需要放上沒有程式集載入時候的數據(具體來說,就是去掉所有 new 那些類的程式碼):

統計

Milestone

Time

第一次

——————————–

——-:

第一次

Main Method Start

43

第一次

Run

75

第二次

Main Method Start

27

第二次

Run

35

第三次

Main Method Start

28

第三次

Run

40

這可以證明,以上時間大部分來源於程式集的載入,而不是其他什麼程式碼。

現在,我們稍稍修改一下程式集,讓 new Foo() 改為使用 lambda 表達式來創建:

    using System;      using System.Threading.Tasks;        namespace Walterlv.Demo      {          public static class Program          {              [STAThread]              private static int Main(string[] args)              {                  var logger = new StartupLogger();                  var startupManagerTask = Task.Run(() =>                  {                      var startup = new StartupManager(logger).ConfigAssemblies(  --                      new Foo(),  --                      new Bar(),  --                      new Xxx(),  --                      new Yyy(),  --                      new Zzz(),  --                      new Www());  ++                      () => new Foo(),  ++                      () => new Bar(),  ++                      () => new Xxx(),  ++                      () => new Yyy(),  ++                      () => new Zzz(),  ++                      () => new Www());                      startup.Run();                      return startup;                  });                    var app = new App(startupManagerTask);                  app.InitializeComponent();                  app.Run();                    return 0;              }          }      }

這時,直到 Run 函數執行時,那些程式集都還沒有載入。由於我在 Run 函數中真正使用到了那些對象,所以其實 Run 中是需要寫程式碼來載入那些程式集的(也是自動)。

如果我們依次載入這些程式集,那麼時間如下:

Milestone

Time

Main Method Start

38

Run

739

如果我們使用 Parallel 並行載入這些程式集,那麼時間如下:

Milestone

Time

Main Method Start

31

Run

493

可以看到,程式集載入時間有明顯增加。

實際上我們完成的任務是一樣的,但是程式集載入時間顯著增加,這顯然不是我們期望的結果。

在上例中,第一個不到 200 ms 的載入時間,來源於我們直接寫下了 new 不同程式集中的類型。後面長一些的時間,則因為我們的 Main 函數中沒有直接構造類型,而是寫成了 lambda 表達式。來源於在 Run 中調用那些 lambda 表達式從而間接載入了類型。

為了更直觀,我把 Run 方法中的關鍵程式碼貼出來:

// assemblies 是直接 new 出來的參數傳進來的。  _assembliesToBeManaged.AddRange(assemblies);
// assemblies 是寫的 lambda 表達式參數傳進來的。  _assembliesToBeManaged.AddRange(assemblies.Select(x => x()));

上面的版本,這些程式集的載入時間是 180 ms,而下面的版本,則達到驚人的 701 ms!

程式集的載入時機

於是我們可以了解到程式集的載入時機。

  • 在一個方法被 JIT 載入的時候,裡面用到的類型所在的程式集就會被載入到應用程式域中。當載入完後,此方法才被執行。
  • 載入程式集時,只會載入方法中會直接使用到的類型,如果是 lambda 內的類型,則會在此 lambda 被調用的時候才會執行(其實這本質上和方法被調用之前的載入是一個時機)。

並且,我們能夠得出性能優化建議:

  • 如果可行,最好讓 CLR 自動管理程式集的載入,而且一次性能載入所有程式集的話就一次性載入,而不要嘗試自己去分開載入這些程式集,那會使得能夠並行的載入程式集的時間變得串列,浪費啟動性能。

本文會經常更新,請閱讀原文: https://blog.walterlv.com/post/when-assemblies-are-loaded.html ,以避免陳舊錯誤知識的誤導,同時有更好的閱讀體驗。

本作品採用 知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議 進行許可。歡迎轉載、使用、重新發布,但務必保留文章署名 呂毅 (包含鏈接: https://blog.walterlv.com ),不得用於商業目的,基於本文修改後的作品務必以相同的許可發布。如有任何疑問,請 與我聯繫 ([email protected])