1. 程式人生 > >使用診斷工具觀察 Microsoft.Extensions.DependencyInjection 2.x 版本的記憶體佔用

使用診斷工具觀察 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()為例簡單說明:

  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/leon