了解 .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])