- 准備工作
- 請求與快照
- 請求耗時的分析
- 請求內存的分析
- 第2次快照與第1次快照的對比:依賴注入加載完成
- 第3次與第2次快照的對比:接口被實例化,委托被緩存
- Microsoft.Extensions.DependencyInjection.ServiceLookup.TransientCallSite +10,000 +320,000 +720,000 10,064 322,048 765,848
- Microsoft.Extensions.DependencyInjection.ServiceLookup.CreateInstanceCallSite +10,000 +400,000 +400,000 10,027 401,080 401,080
- Func
+10,000 +640,768 +1,120,936 10,060 648,536 1,133,768 - ConcurrentDictionary+Node
> +10,000 +480,000 +1,600,936 10,060 482,880 1,616,584 - Microsoft.Extensions.DependencyInjection.ServiceLookup.DynamicServiceProviderEngine+<>c__DisplayClass1_0 +9,997 +479,856 +479,856 10,045 482,160 482,704
- 第4次與第3次快照的對比:使用表達式樹生成委托更新原有委托
- Summary
准備工作
接 Microsoft.Extensions.DependencyInjection 之一:解析實現
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/leoninew