了解 .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; } } }
在這段代碼中,Foo
、Bar
、Xxx
、Yyy
、Zzz
、Www
分別在不同的程序集中,我們姑且認為程序集名稱是 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]) 。