Microsoft.Extensions.DependencyInjection 之三:展開測試
- 2019 年 11 月 6 日
- 筆記
目錄
前文回顧
Microsoft.Extensions.DependencyInjection 之一:解析實現 提到了 Microsoft.Extensions.DependencyInjection 包含以下核心組件。
IServiceCallSite
組件實例化上下文,包含許多實現,僅列舉其中的ConstantCallSite
,CreateInstanceCallSite
,ConstructorCallSite
如下。
ConstructorCallSite
既是IServiceCallSite
的實現類,又複合引用IServiceCallSite
,以表達自身參數的構建形式。
組件生命周期也使用IServiceCallSite
表達,它們既從IServiceCallSite
繼承,也引用IServiceCallSite
。
CallSiteFactory
當組件需要被實例化時,CallSiteFactory
從維護的ServiceDescriptor
查找注入方式,對類型注入的組件使用反射解析其構造函數,並遞歸解析其參數,最後緩存得到的IServiceCallSite
實例。
ServiceProviderEngine
ServiceProviderEngine
是抽象類,內部依賴CallSiteRuntimeResolver
完成基於反射的組件實例化,並緩存了組件實例化的委託。
CompiledServiceProviderEngine
CompiledServiceProviderEngine
從ServiceProviderEngine
繼承,內部依賴ExpressionResolverBuilder
完成基於表達式樹的組件實例化的委託。
DynamicServiceProviderEngine
DynamicServiceProviderEngine
從CompiledServiceProviderEngine
繼承,它創建的委託比較特殊:
- 該委託第1次執行實際是
ServiceProviderEngine
內部的CallSiteRuntimeResolver
調用 - 該委託第2次執行時開啟異步任務,調用
CompiledServiceProviderEngine
內部的ExpressionResolverBuilder
編譯出委託並覆蓋ServiceProviderEngine
內部緩存。
為了印證該邏輯,這裡使用 LINQPad 6 進行探索,該代碼可以在控制台中運行,但部分語句僅在 LINQPad 中生效。
void Main() { var services = new ServiceCollection() .AddTransient<IFoo1, Foo1>() .BuildServiceProvider(); var engine = ReflectionExtensions.GetNonPublicField(services, "_engine"); var realizedServices = (System.Collections.IDictionary)ReflectionExtensions.GetNonPublicProperty(engine, "RealizedServices"); for (int i = 0; i < 3; i++) { services.GetRequiredService<IFoo1>(); //組件實例化 foreach (DictionaryEntry item in realizedServices) { var title = String.Format("Loop {0}, type {1}, hash {2}", i, ((Type)item.Key).FullName, item.Value.GetHashCode()); item.Value.Dump(title, depth: 2); //僅被 LINQPad 支持 } Thread.Sleep(10); //確保異步任務完成 } } class ReflectionExtensions { public static Object GetNonPublicField(Object instance, String name) { var type = instance.GetType(); var field = type.GetField(name, BindingFlags.NonPublic | BindingFlags.Instance); return field.GetValue(instance); } public static Object GetNonPublicProperty(Object instance, String name) { var type = instance.GetType(); var property = type.GetProperty(name, BindingFlags.NonPublic | BindingFlags.Instance); return property.GetValue(instance); } } interface IFoo1 { void Hello(); } class Foo1 : IFoo1 { public void Hello() { Console.WriteLine("Foo1.Hello()"); } }
運行該腳本,可以看到
- 第1次和第2次組件實例化,委託相同,hash 值都是 688136691,都是
Microsoft.Extensions.DependencyInjection.ServiceLookup.DynamicServiceProviderEngine
的內部委託; - 第1次和第2次組件實例化的
callSite
計數從1談到2; - 第3次組件實例例,委託變成了
System.Object lambda_method(...)
,hash 值變成了 1561307880;
LINQPad 5 運行該腳本未能看到 hash 值變化,猜測是優化相關所致,考慮到DEBUG、單元測試和 LINQPad 6 已經實證,不再研究。
Microsoft.Extensions.DependencyInjection 之二:使用診斷工具觀察內存佔用 對比了組件實例化前後的內存變化如下圖,從第3次開始組件實例化的性能大幅提升。
在以上基礎上得到作了小結:
Microsoft.Extensions.DependencyInjection 並非是銀彈,它的便利性是一種空間換時間的典型,我們需要對以下情況有所了解:
- 重度使用依賴注入的大型項目啟動過程相當之慢;
- 如果單次請求需要實例化的組件過多,前期請求的內存開銷不可輕視;
- 由於實例化伴隨着遞歸調用,過深的依賴將不可避免地導致堆棧溢出;
測試參數
Microsoft.Extensions.DependencyInjection 中抽象類Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngine
有以下實現
- Microsoft.Extensions.DependencyInjection.ServiceLookup.CompiledServiceProviderEngine
- Microsoft.Extensions.DependencyInjection.ServiceLookup.DynamicServiceProviderEngine
- Microsoft.Extensions.DependencyInjection.ServiceLookup.ExpressionsServiceProviderEngine
- Microsoft.Extensions.DependencyInjection.ServiceLookup.RuntimeServiceProviderEngine
- Microsoft.Extensions.DependencyInjection.ServiceLookup.ILEmitServiceProviderEngine
RuntimeServiceProviderEngine
是對 ServiceProviderEngine
的原樣繼承可以忽略,ExpressionsServiceProviderEngine
和CompiledServiceProviderEngine
都是表達式樹的使用也沒有差異。ILEmitServiceProviderEngine
是 emit 相關的實現。
從aspnet/DependencyInjection 獲取到的分支release/2.1 後,提取到ServiceProviderEngine
、CompiledServiceProviderEngine
和ILEmitServiceProviderEngine
等核心實現,再編寫控制台程序,對依賴注入中反射、表達式樹、emit 3種實現方式的開銷和性能上進行探索。
程序使用啟動參數控制組件實例化行為,並記錄測試結果,以下是程序啟動參數的解釋。
-m|method
實例化方式,使用反射、表達式樹與 Emit 參與了測試,分別對應:
- ref:使用反射實例化組件,實質是對 CallSiteRuntimeResolver 的調用;
- exp:使用表達式樹實例化組件,實質是對 ExpressionResolverBuilder 的調用;
- emit:使用 emit 實例化組件,實質是對 ILEmitResolverBuilder 的調用;
Action<Type, Boolean> handle = default; if (method == "ref") { handle = GetRefService; } else if (method == "exp") { handle = GetExpService; } else if (method == "emit") { handle = GetEmitService; }
-t|target
實例化目標,使用選取以下兩種,配合參數 -n|number 使用
- foo:使用 IFoo_{n} 作為實例化目標,已定義了 IFoo_0、IFoo_1、IFoo_2 至 IFoo_9999 共1萬個接口與對應實現
- bar:使用 IBar_{n} 作為實例化目標,只定義了 IBar_100、IBar_1000、IBar_5000、IBar_10000 共4個接口,每個實現均以 IFoo 作為構造函數的參數,
- IBar_100:使用 IFoo_0、IFoo_1 至 IFoo_99 作為構造函數參數;
- IBar_1000:使用 IFoo_0、IFoo_1 至 IFoo_999 作為構造函數參數;
- IBar_5000:使用 IFoo_0、IFoo_1 至 IFoo_4999 作為構造函數參數;
- IBar_10000:使用 IFoo_0、IFoo_1 至 IFoo_9999 作為構造函數參數;
該部分同 大量接口與實現類的生成 一樣仍然使用腳本生成。
-n|number
幫助指示實例化的目標及數量
- 100:target = foo 為從 IFoo_0、IFoo_1 至 IFoo_100 共100個接口,target =bar 則僅為 IBar_100;
- 1000:target = foo 為從 IFoo_0、IFoo_1 至 IFoo_1000 共1000個接口,target = bar 則僅為 IBar_1000;
- 5000:target = foo 為從 IFoo_0、IFoo_1 至 IFoo_5000 共5000個接口,target =bar 則僅為 IBar_5000;
- 10000:target = foo 為從 IFoo_0、IFoo_1 至 IFoo_10000 共10000個接口,target =bar 則僅為 IBar_10000;
-c|cache
緩存行為,cache = false 時每次都構建委託,cache = true 則把構建委託緩存起來重複使用。GetRefService()
實現如下,GetExpService()
和GetEmitService()
相似。
static void GetRefService(Type type, Boolean cache) { var site = _expEngine.CallSiteFactory.CreateCallSite(type, new CallSiteChain()); Func<ServiceProviderEngineScope, object> func; if (cache) { func = _expEngine.RealizedServices.GetOrAdd(type, scope => _expEngine.RuntimeResolver.Resolve(site, scope)); } else { func = scope => _expEngine.RuntimeResolver.Resolve(site, scope); _expEngine.RealizedServices[type] = func; } if (func == null) { _logger.Warn("Cache miss"); return; } var obj = func(_expEngine.Root); if (obj == null) { throw new NotImplementedException(); } }
-l|loop
重複執行若干次,每次均記錄測試時長
static void TestBar(Action<Type, Boolean> handle, String method, Boolean cache, Type type) { _watch.Restart(); handle(type, cache); _watch.Stop(); _logger.Info("method {0}, cache {1}, target {2}, cost {3}", method, cache, type.Name, _watch.ElapsedMilliseconds); } ... TestBar(handle, method, false, number); for (int i = 1; i < loop; i++) { TestBar(handle, method, cache, number); }
由於本測試的重點是對比使用反射、表達式樹與 emit 的性能與開銷,故程序啟動後首先遍歷 ServiceCollection
對每個組件調用 CallSiteFactory.CreateCallSite(Type serviceType)
,確保組件的上下文已經被創建和緩存。
啟動測試
對以上參數進行組合,得到以下啟動方式,測試結果異步寫入日誌文件供後續解析。
# 啟用委託緩存行為,實例化以 IFoo_ 作為命名前綴注入的服務 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m ref -t foo -c true -n 100 -l 100 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m ref -t foo -c true -n 1000 -l 100 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m ref -t foo -c true -n 5000 -l 100 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m ref -t foo -c true -n 10000 -l 100 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m exp -t foo -c true -n 100 -l 100 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m exp -t foo -c true -n 1000 -l 100 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m exp -t foo -c true -n 5000 -l 100 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m exp -t foo -c true -n 10000 -l 100 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m emit -t foo -c true -n 100 -l 100 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m emit -t foo -c true -n 1000 -l 100 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m emit -t foo -c true -n 5000 -l 100 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m emit -t foo -c true -n 10000 -l 100 # 禁用委託緩存行為,實例化以 IFoo_ 作為命名前綴注入的服務 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m ref -t foo -c false -n 100 -l 50 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m ref -t foo -c false -n 1000 -l 50 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m ref -t foo -c false -n 5000 -l 50 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m ref -t foo -c false -n 10000 -l 50 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m exp -t foo -c false -n 100 -l 50 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m exp -t foo -c false -n 1000 -l 50 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m exp -t foo -c false -n 5000 -l 50 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m exp -t foo -c false -n 10000 -l 50 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m emit -t foo -c false -n 100 -l 50 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m emit -t foo -c false -n 1000 -l 50 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m emit -t foo -c false -n 5000 -l 50 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m emit -t foo -c false -n 10000 -l 50 # 啟用委託緩存行為,實例化 IBar_100、IBar_1000、IBar_5000、IBar_10000 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m ref -t bar -c true -n 100 -l 100 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m ref -t bar -c true -n 1000 -l 100 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m ref -t bar -c true -n 5000 -l 100 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m ref -t bar -c true -n 10000 -l 100 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m exp -t bar -c true -n 100 -l 100 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m exp -t bar -c true -n 1000 -l 100 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m exp -t bar -c true -n 5000 -l 100 # ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m exp -t bar -c true -n 10000 -l 100 # 請求無法完成,拋出 IL 相關異常 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m emit -t bar -c true -n 100 -l 100 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m emit -t bar -c true -n 1000 -l 100 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m emit -t bar -c true -n 5000 -l 100 # ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m emit -t bar -c true -n 10000 -l 100 # 請求無法完成,拋出 IL 相關異常 # 禁用委託緩存行為,實例化 IBar_100、IBar_1000、IBar_5000、IBar_10000 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m ref -t bar -c false -n 100 -l 50 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m ref -t bar -c false -n 1000 -l 50 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m ref -t bar -c false -n 5000 -l 50 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m ref -t bar -c false -n 10000 -l 50 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m exp -t bar -c false -n 100 -l 50 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m exp -t bar -c false -n 1000 -l 50 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m exp -t bar -c false -n 5000 -l 50 # ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m exp -t bar -c false -n 10000 -l 50 # 請求無法完成,拋出 IL 相關異常 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m emit -t bar -c false -n 100 -l 50 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m emit -t bar -c false -n 1000 -l 50 ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m emit -t bar -c false -n 5000 -l 50 # ./LeoninewAxe.Scaffold.DependencyInjection.App.exe -m emit -t bar -c false -n 10000 -l 50 # 請求無法完成,拋出 IL 相關異常
值得一提的是,表達式樹和 emit 均無法完成 IBar_10000 的實例化,執行中拋出相同異常 "System.InvalidProgramException: The JIT compiler encountered invalid IL code or an internal limitation."
測試結果
使用 LINQPad 編寫腳本解析日誌,對解析結果使用 Excel 透視作表,得到耗時平均值與標準差。
對於本測試使用到的以 IFoo_ 和 IBar_ 作為命名前綴的接口來說:
- 1萬餘接口的注入時間為 10s 左右;
- 1萬餘接口的組件的上下文創建時間在 0.6s 左右;
- 開啟委託緩存時,所有實例化方式都能獲益;
- 所有方式實例化 IBar_N 均比實例化 IFoo_0 至 IFoo_N 快非常多;
反射
- 對緩存不敏感(控制台為 dotnet 3.0 版本);
- 組件數量增長時,內存使用平穩,完成10000個 IFoo 實例化完成後進程內存增長 49.1MB-35.9MB=13.2MB;
表達式樹
- 隨着組件數量增長,對緩存越發敏感;
- 內存需求增長,完成10000個 IFoo 實例化後進程內存增長75.7MB-35.9MB=39.8MB;
- 實例化依賴眾多的組件時,在緩存下的耗時幾乎忽略;
Emit 與表達式差異不大
- 同表達式樹對緩存敏感
- 內存需求增長,完成10000個 IFoo 實例化後進程內存增長77.7MB-35.9MB=41.8MB;
- 耗時更不穩定
開銷對比
對比1:開啟緩存,實例化 IFoo_ 相關組件
對比2:開啟緩存,實例化 IBar_ 相關組件
表達樹與 emit 方式均無法完成實例化 IBar_10000
對比3:關閉緩存,實例化 IFoo_ 相關組件
對比4:關閉緩存,實例化 IBar_ 相關組件
表達樹與 emit 方式均無法完成實例化 IBar_10000
相對於使用反射來說,不開啟緩存時表達式樹和 emit 既慢內存消耗又高——無論是實例化 IFoo_ 相關組件還是 IBar_ 相關組件,它們均達到更高的內存佔用,又頻繁地觸發 GC,最終 CPU 使用率居高不下。
測試中未使用
GC.SuppressFinalize()
處理實例化得到的組件,大量的 IFoo_ 實例回收影響判斷,IBar_ 沒有這個問題故放出截圖。