使用診斷工具觀察 Microsoft.Extensions.DependencyInjection 2.x 版本的記憶體佔用
目錄
- 準備工作
- 大量介面與實現類的生成
- elasticsearch+kibana+apm
- asp.net core 應用
- 請求與快照
- Kibana 上的請求記錄
- 請求耗時的分析
- 請求記憶體的分析
- 第2次快照與第1次快照的對比:依賴注入載入完成
- 第3次與第2次快照的對比:介面被例項化,委託被快取
- 第4次與第3次快照的對比:使用表示式樹生成委託更新原有委託
- Summary
準備工作
Visual Studio 從2015 版本起攜帶了診斷工具,可以很方便地進行實時的記憶體與 CPU 分析,將大家從記憶體 dump 和 windbg 中解放出來。本文使用大量介面進行注入與例項化測試以觀察記憶體佔用,除 Visual Studio 外還需要以下準備工作。
- 大量介面與實現類的生成(可選),見下方
- elasticsearch+kibana+apm,見下方
- asp.net core 應用,見下方
大量介面與實現類的生成
使用 TypeScript 迴圈生成了1萬個介面,寫入專案的 Foo.cs 檔案
import * as commander from 'commander'; import * as format from 'string-template'; let prefix = `using Microsoft.Extensions.DependencyInjection; using System; using System.Collections.Generic; using System.Text; namespace WebApplication1 {`; let fooTemplate =` interface IFoo\_{n} { void Hello(); } class Foo_{n} : IFoo\_{n} { public void Hello() { } } `; (async function () { let args = commander .version('0.0.1') .option('-n, --count [value]') .parse(process.argv); let count = parseInt(args.count, 10); console.log(prefix); for (let i = 0; i < count; i++) { let src = format(fooTemplate, {n: i}); console.log(src); } console.log('}'); })();
通過引數count
控制生成的介面與實現類的數量,再使用 Shell 將列印內容寫入 CSharpe 檔案中。
T480@PC-XXXXXXXXX ~/source/repos/gvp-integration-test
$ ts-node test -n 10000 > ~/source/repos/WebApplication1/WebApplication1/Foo.cs
於是我們擁有了 IFoo_0 到 IFoo_9999 這1萬個介面與對應的實現。
該方式是可選的,相當多的工具或者手寫程式碼均可達到目的。
然後在程式啟動時使用反射注入以 IFoo_ 相關的介面與其實現。
var types = Assembly.GetExecutingAssembly().GetTypes(); var fooInterfaces = types.Where(x => x.IsInterface && x.Name.StartsWith("IFoo_")); foreach (var item in fooInterfaces) { var impl = types.Single(x => x.IsClass && item.IsAssignableFrom(x)); services.AddTransient(item, impl); }
elasticsearch+kibana+apm
使用 docker-compose 完成部署,相關文件很多,不是本文的關注點,略。
asp.net core 應用
添加了以下依賴,使用上述生成的1萬個介面進行測試。
- Microsoft.Extensions.DependencyInjection,版本 2.11
- Elastic.Apm.NetCoreAll,版本 1.1.2
路由 /api/realized/get-many
的邏輯是獲取大量以 IFoo_ 作為字首命名的介面的例項,通過 queryString 中的 count 控制獲取的數量,實現如下:
[HttpGet("get-many")]
public void GetManyService(Int32 count = -1)
{
_logger.LogInformation("[GetManyService] start");
var fooInterfaces = Assembly.GetExecutingAssembly().GetTypes()
.Where(x => x.IsInterface && x.Name.StartsWith("IFoo"));
if (count > -1)
{
fooInterfaces = fooInterfaces.Where(x => Int32.Parse(x.Name.Split('_')[1]) < count);
}
using (CurrentTransaction.Start(nameof(GetManyService), "GetRequiredService"))
{
foreach (var item in fooInterfaces)
{
_services.GetRequiredService(item);
}
};
_logger.LogDebug("[GetManyService] finish");
}
請求與快照
程式啟動和執行期間獲取了5份快照,分別在以下時機:
- 第1次快照:應用程式啟動後,程序記憶體約76.4MB;
- 第2次快照:依賴注入載入完成,程序記憶體約248.9MB;
- 第3次快照:第1次請求
/api/realized/get-many?count=10000
,迴圈獲取前述1萬個IFoo\_N
介面後,程序記憶體約 271.8MB; - 第4次快照:第2次請求
/api/realized/get-many?count=10000
,程序記憶體約 308.2MB; - 第5次快照:連續地請求
/api/realized/get-many?count=10000
若干次後呼叫一次 GC,程序記憶體約 305.2MB;
Kibana 上的請求記錄
下圖顯示了 Kibana 記錄的所有的請求,下圖中 transaction.type=request 的是 HTTP 請求,url.path 是請求地址,記錄以時間倒序。其他記錄是由 elastic/apm 生成的。
- transaction.duration.us:單次請求的耗時,微秒單位;
- span.duration.us:發生在請求 /api/realized/get-many 的內部,獲取大量以 IFoo_ 命名介面例項的耗時,微秒單位;
請求耗時的分析
請求的主體邏輯是獲取大量以 IFoo_ 命名介面例項,僅觀察請求級別的耗時變化,就能夠反映獲取大量以 IFoo_ 命名介面例項的效率變化:
- 第1次完成 /api/realized/get-many 耗時 931ms;
- 第2次完成 /api/realized/get-many 耗時 301ms;
- 第3次及後續完成 /api/realized/get-many 耗時在 16ms-32ms 之前;
請求記憶體的分析
5 次快照的簡要資料如下
ID | Time | Live Objects | Managed Heap | 程序記憶體 |
---|---|---|---|---|
1 | 4.29s | 25455 | 2123.18KB | 76.4MB |
2 | 31.29s | 73429(+47974) | 6525.04KB(+4401.86KB) | 248.9MB |
3 | 39.69s | 124907(+51478) | 9605.48KB(+3080.45KB) | 271.8MB |
4 | 48.09s | 377403(+252496) | 25139.20KB(+15533.72KB) | 308.2MB |
5 | 62.64s | 378407(+1004) | 25224.86KB(+85.66KB) | 305.2MB |
第2次快照與第1次快照的對比:依賴注入載入完成
獲取第1次快照時應用程式處於啟動中,觀察記憶體平穩後獲取第2次快照,故兩次快照的差異是由註冊依賴注入方式產生的。由上一篇文章關於CallSiteFactory
的內容已知,註冊依賴注入方式的過程是,是ServiceDescriptor
的建立過程。
在此過程中程序記憶體增長了248.9MB-76.4MB=172.5MB,但值得一說的是即便零自定義注入,asp.net core 應用完成啟動後也會有相當幅度的記憶體增長,需要橫向對比。
我們注入了1萬個以 IFoo_ 作為命名字首的介面與其實現,它們被新增到注入方式集合即 ServiceDescriptor
數量,同時 asp.net core 自身的基礎設定同樣以此方式載入,故最終多於 1萬條記錄。
Microsoft.Extensions.DependencyInjection.ServiceDescriptor +10,192 +570,752 +575,168 10,238 573,328 583,472
由於CallSiteFactory
使用內部成員List<ServiceDescriptor> _descriptors
持有了所有注入方式的列表,故其引用數量增加。
List<Microsoft.Extensions.DependencyInjection.ServiceDescriptor> +20,498 20,549
雖然注入方式列表有1萬多條,但它們會被第一時間分組,導致引用數量翻倍成2萬多條,見下方描述。
Dictionary<Type, Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory+ServiceDescriptorCacheItem> +10,210 10,251
CallSiteFactory
使用 List<ServiceDescriptor>
作為建構函式引數,在例項化的同時對注入方式進行了分組,分組結果儲存在內部成員Dictionary<Type, ServiceDescriptorCacheItem> _descriptorLookup
中。
第3次與第2次快照的對比:介面被例項化,委託被快取
發起第1次請求 /api/realized/get-many?count=10000
後獲取了第3次快照,快照的差異由大量以 IFoo_ 作為字首的介面被例項化的過程中產生的。
在此過程中程序記憶體增長了271.8MB-248.9MB=22.9MB。
根據前文描述,我們知道了CallSiteFactory
完成了目標例項上下文 即IServiceCallSite
的建立,並以內部字典 Dictionary<Type, IServiceCallSite> _callSiteCache
進行了快取。
Microsoft.Extensions.DependencyInjection.ServiceLookup.TransientCallSite +10,000 +320,000 +720,000 10,064 322,048 765,848
本實踐中使用的以 Foo_ 作為命名字首的實現均為無參建構函式,故生成1萬個CreateInstanceCallSite
例項,且獨佔記憶體與非獨佔記憶體以相同幅度增長。
回顧CallSiteFactory
建立目標服務例項化的上下文IServiceCallSite
過程:
CallSiteFactory
對不同注入方式有選取優先順序,優先選取例項注入方式,其次選取委託注入方式,最後選取型別注入方式,以TryCreateExact()
為例簡單說明:
- 對於使用單例和常量的注入方式,返回
ConstantCallSite
例項;- 對於使用委託的注入方式,返回
FactoryCallSite
例項;- 對於使用型別注入的,
CallSiteFactory
呼叫方法CreateConstructorCallSite()
;
- 如果只有1個建構函式
- 無參建構函式,使用
CreateInstanceCallSite
作為例項化上下文;- 有參建構函式存,首先使用方法
CreateArgumentCallSites()
遍歷所有引數,遞迴建立各個引數的IServiceCallSite
例項,得到陣列。接著使用前一步得到的陣列作為引數, 創建出 >ConstructorCallSite
例項。- 如果多於1個建構函式,檢查和選取最佳建構函式再使用前一步邏輯處理;
- 最後新增生命週期標識
Microsoft.Extensions.DependencyInjection.ServiceLookup.CreateInstanceCallSite +10,000 +400,000 +400,000 10,027 401,080 401,080
目標服務例項化的上下文IServiceCallSite
被建立完成後,將新增生命週期標識(見截圖的 ApplyLifetime()
方法。
本實踐中全部使用了 Transient
生命週期標識,故生成1萬個TransientCallSite
例項,並引用 CreateInstanceCallSite
例項,使獨佔記憶體與非獨佔記憶體以不同幅度增長。
計算獨佔記憶體增長與非獨佔記憶體增長,320,000+400,000=720,000 可以印證。
Object Type | Size.(Bytes) | Inclusive Size Diff.(Bytes) |
---|---|---|
Microsoft.Extensions.DependencyInjection.ServiceLookup.TransientCallSite | 320,000 | 720,000 |
Microsoft.Extensions.DependencyInjection.ServiceLookup.CreateInstanceCallSite | 400,000 | 400,000 |
Func<Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope, Object> +10,000 +640,768 +1,120,936 10,060 648,536 1,133,768
第1次請求完成後,ServiceProviderEngine.CreateServiceAccessor()
呼叫子類的DynamicServiceProviderEngine.RealizeService()
方法返回1萬個委託。
ConcurrentDictionary+Node<Type, Func<Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope, Object>> +10,000 +480,000 +1,600,936 10,060 482,880 1,616,584
這1萬個委託被 ServiceProviderEngine
快取在成員 ConcurrentDictionary<Type, Func<ServiceProviderEngineScope, object>> RealizedServices
中。
Microsoft.Extensions.DependencyInjection.ServiceLookup.DynamicServiceProviderEngine+<>c__DisplayClass1_0 +9,997 +479,856 +479,856 10,045 482,160 482,704
DynamicServiceProviderEngine.RealizeService()
返回的是匿名委託,經常使用反編譯工具的同學知道這是編譯器行為以進行變數捕獲。為什麼是 9997 而不是1萬,推測是匿名委託被編譯的過程還沒有完成,可以從下文引用數的減少看到。由於數字不再精確,只簡單列舉引用記憶體佔用不再計算。
Object Type | Size.(Bytes) | Inclusive Size Diff.(Bytes) |
---|---|---|
Func<Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope, Object> | 640,768 | 1120,936 |
ConcurrentDictionary+Node<Type, Func<Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope, Object>> | 480,000 | 1600,936 |
Microsoft.Extensions.DependencyInjection.ServiceLookup.DynamicServiceProviderEngine+<>c__DisplayClass1_0 | 479,856 | 479,856 |
第4次與第3次快照的對比:使用表示式樹生成委託更新原有委託
第2次請求 /api/realized/get-many
時,非同步執行緒啟動,ExpressionsServiceProviderEngine
依賴的ExpressionResolverBuilder
使用表示式樹重新生成委託,並覆蓋到原有快取中。由於在請求完成且記憶體佔用平穩後獲取快照,可以認為表示式樹解析已經完成,委託已經被全部替換,故對比快照反應了兩種委託的開銷差異。
在此過程中程序記憶體增長了308.2MB-271.8MB=36.4MB,對比第2次快照為308.2MB-248.9MB=59.3MB,可見表示式樹對記憶體來說非常不經濟。
反序排列引用數量,可以觀察到前一步生成的1萬個 Microsoft.Extensions.DependencyInjection.ServiceLookup.DynamicServiceProviderEngine+<>c__DisplayClass1_0 已經被釋放。
正序排列引用數量。RuntimeMethodHandle 為表示式樹生成的相關方法,由於相關知識儲備不到位,不再展開。
第5次的快照與第4次快照相對,記憶體變化幅度不大,略過。
Summary
在 Kibana 上作表與製圖如下
前文結論見請求耗時的分析,得到了印證:
為了在效能與開銷中獲取平衡,
Microsoft.Extensions.DependencyInjection
在初次請求時使用反射例項化目標服務並快取委託,再次請求時非同步使用表示式樹生成委託並更新快取,使得後續請求效能得到了提升。
- 第1次請求使用反射完成目標服務的例項化,並將例項化的委託快取,這是第2次請求比第1次的高效原因;
- 第2次請求的後臺任務使用表示式樹重新生成委託,使得第3次請求比第2次請求效率提升了一個數量級;
- 後續請求和第3次請求差別不大;
Microsoft.Extensions.DependencyInjection 並非是銀彈,它的便利性是一種空間換時間的典型,我們需要對以下情況有所瞭解:
- 重度使用依賴注入的大型專案啟動過程相當之慢;
- 如果單次請求需要例項化的目標服務過多,前期請求的記憶體開銷不可輕視;
- 由於例項化伴隨著遞迴呼叫,過深的依賴將不可避免地導致堆疊溢位;
leoninew 原創,轉載請保留出處 www.cnblogs.com/leon