Microsoft.Extensions.DependencyInjection 之三:展開測試

  • 2019 年 11 月 6 日
  • 筆記

前文回顧

Microsoft.Extensions.DependencyInjection 之一:解析實現 提到了 Microsoft.Extensions.DependencyInjection 包含以下核心組件。

IServiceCallSite

組件實例化上下文,包含許多實現,僅列舉其中的ConstantCallSiteCreateInstanceCallSiteConstructorCallSite如下。

ConstructorCallSite既是IServiceCallSite的實現類,又複合引用IServiceCallSite,以表達自身參數的構建形式。

組件生命周期也使用IServiceCallSite表達,它們既從IServiceCallSite繼承,也引用IServiceCallSite

CallSiteFactory

當組件需要被實例化時,CallSiteFactory從維護的ServiceDescriptor查找注入方式,對類型注入的組件使用反射解析其構造函數,並遞歸解析其參數,最後快取得到的IServiceCallSite實例。

ServiceProviderEngine

ServiceProviderEngine是抽象類,內部依賴CallSiteRuntimeResolver完成基於反射的組件實例化,並快取了組件實例化的委託。

CompiledServiceProviderEngine

CompiledServiceProviderEngineServiceProviderEngine繼承,內部依賴ExpressionResolverBuilder完成基於表達式樹的組件實例化的委託。

DynamicServiceProviderEngine

DynamicServiceProviderEngineCompiledServiceProviderEngine繼承,它創建的委託比較特殊:

  • 該委託第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 的原樣繼承可以忽略,ExpressionsServiceProviderEngineCompiledServiceProviderEngine都是表達式樹的使用也沒有差異。ILEmitServiceProviderEngine是 emit 相關的實現。

aspnet/DependencyInjection 獲取到的分支release/2.1 後,提取到ServiceProviderEngineCompiledServiceProviderEngineILEmitServiceProviderEngine等核心實現,再編寫控制台程式,對依賴注入中反射、表達式樹、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_ 沒有這個問題故放出截圖。