Microsoft.Extensions.DependencyInjection 之二:使用診斷工具觀察內存占用



准備工作

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()為例簡單說明:

  1. 對於使用單例和常量的注入方式,返回ConstantCallSite實例;
  2. 對於使用委托的注入方式,返回FactoryCallSite實例;
  3. 對於使用類型注入的,CallSiteFactory調用方法CreateConstructorCallSite()
    • 如果只有1個構造函數
      • 無參構造函數,使用 CreateInstanceCallSite作為實例化上下文;
      • 有參構造函數存,首先使用方法CreateArgumentCallSites()遍歷所有參數,遞歸創建各個參數的 IServiceCallSite 實例,得到數組。接着使用前一步得到的數組作為參數, 創建出 > ConstructorCallSite實例。
    • 如果多於1個構造函數,檢查和選取最佳構造函數再使用前一步邏輯處理;
  4. 最后添加生命周期標識

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM