使用诊断工具观察 Microsoft.Extensions.DependencyInjection 2.x 版本的内存占用

准备工作

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 _descriptors 持有了所有注入方式的列表,故其引用数量增加。

List +20,498 20,549

虽然注入方式列表有1万多条,但它们会被第一时间分组,导致引用数量翻倍成2万多条,见下方描述。

Dictionary +10,210 10,251

CallSiteFactory 使用 List 作为构造函数参数,在实例化的同时对注入方式进行了分组,分组结果存储在内部成员 Dictionary _descriptorLookup 中。

第3次与第2次快照的对比:接口被实例化,委托被缓存

发起第1次请求 /api/realized/get-many?count=10000 后获取了第3次快照,快照的差异由大量以 IFoo_ 作为前缀的接口被实例化的过程中产生的。

在此过程中进程内存增长了271.8MB-248.9MB=22.9MB。

根据前文描述,我们知道了 CallSiteFactory 完成了目标实例上下文 即 IServiceCallSite 的创建,并以内部字典 Dictionary _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 +10,000 +640,768 +1,120,936 10,060 648,536 1,133,768

第1次请求完成后, ServiceProviderEngine.CreateServiceAccessor() 调用子类的 DynamicServiceProviderEngine.RealizeService() 方法返回1万个委托。

ConcurrentDictionary+Node<Type, Func> +10,000 +480,000 +1,600,936 10,060 482,880 1,616,584

这1万个委托被 ServiceProviderEngine 缓存在成员 ConcurrentDictionary<Type, Func> 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 640,768 1120,936
ConcurrentDictionary+Node<Type, Func> 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