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