动态细节:编译器秘密游戏,内存泄漏,性能差异

前戏



考虑以下代码:

//Any native COM object var comType = Type.GetTypeFromCLSID(new Guid("E13B6688-3F39-11D0-96F6-00A0C9191601")); while (true) { dynamic com = Activator.CreateInstance(comType); //do some work Marshal.FinalReleaseComObject(com); } 


Marshal.FinalReleaseComObject方法的签名如下:

 public static int FinalReleaseComObject(Object o) 


我们创建一个简单的COM对象,做一些工作,然后立即释放它。 看来可能出什么问题了? 是的,在无限循环内创建对象不是一个好习惯,但是GC将承担所有肮脏的工作。 实际情况略有不同:



要了解内存泄漏的原因,您需要了解动态的工作原理。 在Habré上已经有关于此主题的几篇文章,例如, 这篇文章 ,但是它们没有详细介绍实现的细节,因此我们将进行自己的研究。



首先,我们将详细研究动态工作机制,然后将获得的知识简化为一张图片,最后,我们将讨论造成这种泄漏的原因以及如何避免这种泄漏。 在深入研究代码之前,让我们澄清一下源数据:哪些因素导致泄漏?

实验



也许创建许多本机COM对象本身并不是一个好主意? 让我们检查一下:

 //Any native COM object var comType = Type.GetTypeFromCLSID(new Guid("E13B6688-3F39-11D0-96F6-00A0C9191601")); while (true) { dynamic com = Activator.CreateInstance(comType); } 


这次一切都很好:



让我们回到代码的原始版本,但是更改对象的类型:

 //Any managed type include managed COM var type = typeof(int); while (true) { dynamic com = Activator.CreateInstance(type); //do some work Marshal.FinalReleaseComObject(com); } 


再一次,不出意外:



让我们尝试第三个选项:

 //Simple COM object var comType = Type.GetTypeFromCLSID(new Guid("435356F9-F33F-403D-B475-1E4AB512FF95")); while (true) { dynamic com = Activator.CreateInstance(comType); //do some work Marshal.FinalReleaseComObject((object) com); } 


现在,我们绝对应该得到相同的行为! ?? 否:(



如果您将com声明为对象或使用Managed COM,则情况类似。 总结实验结果:

  1. 本身实例化本机COM对象不会导致泄漏-GC成功处理了清除内存的问题
  2. 使用任何托管类时,都不会发生泄漏
  3. 当将对象显式转换为对象时 ,一切都很好


展望未来,第一点我们可以添加一个事实,即单独使用动态对象(调用方法或使用属性)也不会导致泄漏。 结论本身表明:当我们传递包含本机COM作为方法参数的动态对象(不进行“手动”类型转换)时,就会发生内存泄漏。

我们需要更深入



现在该记住这种动态 的意义了

快速参考
C#4.0提供了一种新型的dynamic 。 此类型避免了编译器进行的静态类型检查。 在大多数情况下,它用作对象类型。 在编译时,假定声明为动态的元素支持任何操作。 这意味着您不必考虑对象的来源-COM API,IronPython之类的动态语言,使用反射或其他地方。 而且,如果代码无效,则会在运行时引发错误。

例如,如果以下代码中的exampleMethod1方法仅具有一个参数,则编译器会识别到对ec.exampleMethod1(10,4)方法的第一次调用是无效的,因为它包含两个参数。 这将导致编译错误。 编译器不会检查第二个方法调用dynamic_ec.exampleMethod1(10,4) ,因为dynamic_ec被声明为dynamic 。 不会有编译错误。 但是,该错误不会永远不会被忽略-将在运行时检测到。

 static void Main(string[] args) { ExampleClass ec = new ExampleClass(); //      ,  exampleMethod1    . //ec.exampleMethod1(10, 4); dynamic dynamic_ec = new ExampleClass(); //      ,  //      dynamic_ec.exampleMethod1(10, 4); //        ,  //  ,      dynamic_ec.someMethod("some argument", 7, null); dynamic_ec.nonexistentMethod(); } 


 class ExampleClass { public ExampleClass() { } public ExampleClass(int v) { } public void exampleMethod1(int i) { } public void exampleMethod2(string str) { } } 




使用动态变量的代码在编译过程中会发生重大变化。 这段代码:

 dynamic com = Activator.CreateInstance(comType); Marshal.FinalReleaseComObject(com); 


变成以下内容:

 object instance = Activator.CreateInstance(typeFromClsid); // ISSUE: reference to a compiler-generated field if (Foo.o__0.p__0 == null) { // ISSUE: reference to a compiler-generated field Foo.o__0.p__0 = CallSite<Action<CallSite, Type, object>>.Create(Binder.InvokeMember(CSharpBinderFlags.ResultDiscarded, "FinalReleaseComObject", (IEnumerable<Type>) null, typeof (Foo), (IEnumerable<CSharpArgumentInfo>) new CSharpArgumentInfo[2] { CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.UseCompileTimeType | CSharpArgumentInfoFlags.IsStaticType, (string) null), CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, (string) null) })); } // ISSUE: reference to a compiler-generated field // ISSUE: reference to a compiler-generated field Foo.o__0.p__0.Target((CallSite) Foo.o__0.p__0, typeof (Marshal), instance); 


其中o__0是生成的静态类,而p__0是其中的静态字段:

 private class o__0 { public static CallSite<Action<CallSite, Type, object>> p__0; } 


注意:对于每次与dynamic的交互,都会创建一个CallSite字段。 稍后将看到,这对于优化性能是必需的。

请注意,这里没有提到动态 -我们的对象现在存储在类型为object的变量中。 让我们来看一下生成的代码。 首先,创建一个绑定,该绑定描述了我们正在做什么以及正在做什么:

 Binder.InvokeMember(CSharpBinderFlags.ResultDiscarded, "FinalReleaseComObject", (IEnumerable<Type>) null, typeof (Foo), (IEnumerable<CSharpArgumentInfo>) new CSharpArgumentInfo[2] { CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.UseCompileTimeType | CSharpArgumentInfoFlags.IsStaticType, (string) null), CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, (string) null) }) 


这是对我们动态操作的描述。 让我提醒您,我们将动态变量传递给FinalReleaseComObject方法。

  • CSharpBinderFlags.ResultDiscarded-将来不再使用方法执行的结果
  • “ FinalReleaseComObject”-调用方法的名称
  • typeof(Foo)-操作上下文; 通话类型


CSharpArgumentInfo-绑定参数的描述。 在我们的情况下:

  • CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.UseCompileTimeType | CSharpArgumentInfoFlags.IsStaticType,(字符串)null)-第一个参数的说明-元帅类:它是静态的,绑定时应考虑其类型
  • CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None,(string)null)-方法参数的描述,通常没有附加信息。


如果不是调用方法的问题,而是例如从动态对象调用属性的问题,那么将只有一个CSharpArgumentInfo描述动态对象本身。

CallSite是动态表达式的包装。 它对我们包含两个重要领域:

  • 公开T更新
  • 公众目标


从生成的代码中可以明显看出,当执行任何操作时,都会使用描述它的参数调用Target

 Foo.o__0.p__0.Target((CallSite) Foo.o__0.p__0, typeof (Marshal), instance); 


与上述CSharpArgumentInfo结合使用此代码意味着以下含义:您需要使用实例参数在静态Marshal类上调用FinalReleaseComObject方法。 在第一次调用时,与Update中相同的委托存储在Target中。 更新委托负责两项重要任务:

  1. 将动态操作绑定到静态操作(出价机制本身不在本文讨论范围之内)
  2. 缓存形成


我们对第二点感兴趣。 这里应该注意的是,当使用动态对象时,我们需要每次检查操作的有效性。 这是一项相当耗费资源的任务,因此我想缓存此类检查的结果。 关于调用带有参数的方法,我们需要记住以下几点:

  1. 调用方法的类型
  2. 参数传递的对象的类型(确保可以将其强制转换为参数的类型)
  3. 操作有效吗


然后,当再次调用Target时,我们不需要执行相对昂贵的绑定:只需比较类型,如果匹配,就调用目标函数。 为了解决此问题,为每个动态操作创建一个ExpressionTree ,该树存储动态表达式绑定到的约束目标函数

此函数可以有两种类型:

  • 绑定错误 :例如,在不存在的动态对象上调用方法,或者无法将动态对象转换为传递给它的参数的类型:然后,您需要引发类似Microsoft.CSharp.RuntimeBinderException的异常:'NoSuchMember'
  • 挑战是合法的:然后执行所需的操作


ExpressionTreeUpdate委托执行期间形成,并存储在Target中目标 -L0缓存,稍后我们将详细讨论缓存。

因此, Target存储通过Update委托生成的最后一个ExpressionTree 。 让我们看看这个规则看起来像一个传递给Boo方法的Managed类型的示例:

 public class Foo { public void Test() { var type = typeof(int); dynamic instance = Activator.CreateInstance(type); Boo(instance); } public void Boo(object o) { } } 


 .Lambda CallSite.Target<System.Action`3[Actionsss.CallSite,ConsoleApp12.Foo,System.Object]>( Actionsss.CallSite $$site, ConsoleApp12.Foo $$arg0, System.Object $$arg1) { .Block() { .If ($$arg0 .TypeEqual ConsoleApp12.Foo && $$arg1 .TypeEqual System.Int32) { .Return #Label1 { .Block() { .Call $$arg0.Boo((System.Object)((System.Int32)$$arg1)); .Default(System.Object) } } } .Else { .Default(System.Void) }; .Block() { .Constant<Actionsss.Ast.Expression>(IIF((($arg0 TypeEqual Foo) AndAlso ($arg1 TypeEqual Int32)), returnUnamedLabel_0 ({ ... }) , default(Void))); .Label .LabelTarget CallSiteBinder.UpdateLabel: }; .Label .If ( .Call Actionsss.CallSiteOps.SetNotMatched($$site) ) { .Default(System.Void) } .Else { .Invoke (((Actionsss.CallSite`1[System.Action`3[Actionsss.CallSite,ConsoleApp12.Foo,System.Object]])$$site).Update)( $$site, $$arg0, $$arg1) } .LabelTarget #Label1: } } 


对我们来说最重要的障碍:

 .If ($$arg0 .TypeEqual ConsoleApp12.Foo && $$arg1 .TypeEqual System.Int32) 


$$ arg0$$ arg1是用于调用Target的参数:
 Foo.o__0.p__0.Target((CallSite) Foo.o__0.p__0, <b>this</b>, <b>instance</b>); 


翻译成人类,这意味着:

我们已经验证了,如果第一个参数的类型为Foo ,第二个参数的类型为Int32 ,则可以安全地调用Boo((object)$$ arg1)

 .Return #Label1 { .Block() { .Call $$arg0.Boo((System.Object)((System.Int32)$$arg1)); .Default(System.Object) } 


注意:如果发生绑定错误,Label1块如下所示:
 .Return #Label1 { .Throw .New Microsoft.CSharp.RuntimeBinderException("NoSuchMember") 


这些检查称为约束限制有两种类型:按对象的类型和按对象的特定实例(对象必须完全相同)。 如果至少一项限制失败,我们将必须重新检查动态表达式的有效性,为此,我们将调用Update委托。 根据我们已知的方案,他将执行与新类型的绑定,并将新的ExpressionTree保存在Target中

快取



我们已经发现TargetL0缓存 。 每次调用Target时 ,我们要做的第一件事就是遍历已经存储在其中的限制。 如果限制失败并且生成了新的绑定,那么旧规则将同时进入L1L2 。 将来,当您错过L0缓存时,将搜索L1L2中的规则,直到找到合适的规则为止。

  • L1 :剩下L0的最后十条规则(直接存储在CallSite中
  • L2 :使用特定的绑定程序实例(每个CallSite唯一的CallSiteBinder )创建的最后128条规则


现在,我们终于可以将这些详细信息添加到一个整体中,并以算法的形式描述调用Foo.Bar(someDynamicObject)时发生的情况:

1.创建一个活页夹,该活页夹在其签名级别记住上下文和被调用的方法

2.首次调用该操作时,将创建ExpressionTree ,该存储以下内容:
2.1 局限性 。 在这种情况下,这将是当前绑定参数类型的两个限制
2.2 目标函数抛出一些异常 (在这种情况下是不可能的,因为任何动态都会成功导致对象)或调用Bar方法

3.编译并执行生成的ExpressionTree

4.调用该操作时,可能有两个选项:
4.1 工作限制 :只需致电Bar
4.2 限制无效 :对新的绑定参数重复步骤2

因此,以Managed类型的示例为例,几乎可以清楚地看出动态是如何从内部工作的。 在描述的情况下,我们永远不会错过缓存,因为类型始终相同*,因此,在初始化CallSite时, Update将被完全调用一次。 然后,对于每次调用,将仅检查限制,并且将立即调用目标函数。 这与我们对内存的观察非常吻合:没有计算-没有泄漏。

*因此,编译器会为每个生成其CallSites:丢失L0缓存的可能性大大降低

现在是时候找出这种方案与本地COM对象的不同之处了。 让我们看一下ExpressionTree

 .Lambda CallSite.Target<System.Action`3[Actionsss.CallSite,ConsoleApp12.Foo,System.Object]>( Actionsss.CallSite $$site, ConsoleApp12.Foo $$arg0, System.Object $$arg1) { .Block() { .If ($$arg0 .TypeEqual ConsoleApp12.Foo && .Block(System.Object $var1) { $var1 = .Constant<System.WeakReference>(System.WeakReference).Target; $var1 != null && (System.Object)$$arg1 == $var1 }) { .Return #Label1 { .Block() { .Call $$arg0.Boo((System.__ComObject)$$arg1); .Default(System.Object) } } } .Else { .Default(System.Void) }; .Block() { .Constant<Actionsss.Ast.Expression>(IIF((($arg0 TypeEqual Foo) AndAlso {var Param_0; ... }), returnUnamedLabel_1 ({ ... }) , default(Void))); .Label .LabelTarget CallSiteBinder.UpdateLabel: }; .Label .If ( .Call Actionsss.CallSiteOps.SetNotMatched($$site) ) { .Default(System.Void) } .Else { .Invoke (((Actionsss.CallSite`1[System.Action`3[Actionsss.CallSite,ConsoleApp12.Foo,System.Object]])$$site).Update)( $$site, $$arg0, $$arg1) } .LabelTarget #Label1: } } 


可以看出,区别仅在于第二个限制:

 .If ($$arg0 .TypeEqual ConsoleApp12.Foo && .Block(System.Object $var1) { $var1 = .Constant<System.WeakReference>(System.WeakReference).Target; $var1 != null && (System.Object)$$arg1 == $var1 }) 


如果在托管代码的情况下,我们对对象的类型有两个限制,那么在这里我们看到第二个限制通过WeakReference检查实例的等效性。

注意:除COM对象外,实例限制也用于TransparentProxy

实际上,根据我们对缓存操作的了解,这意味着每次我们在循环中重新创建COM对象时,我们都会错过L0缓存(以及L1 / L2 ,因为带有链接的旧规则将存储在此处)到旧实例)。 首先要问您的是规则缓存正在流动。 但是那里的代码非常简单,那里的一切都很好:正确删除了旧规则。 同时,在ExpressionTree中使用WeakReference不会阻止GC收集不必要的对象。

在L1缓存中保存规则的机制:

 const int MaxRules = 10; internal void AddRule(T newRule) { T[] rules = Rules; if (rules == null) { Rules = new[] { newRule }; return; } T[] temp; if (rules.Length < (MaxRules - 1)) { temp = new T[rules.Length + 1]; Array.Copy(rules, 0, temp, 1, rules.Length); } else { temp = new T[MaxRules]; Array.Copy(rules, 0, temp, 1, MaxRules - 1); } temp[0] = newRule; Rules = temp; } 


那怎么办? 让我们尝试阐明这个假设:绑定COM对象时某处发生内存泄漏。

实验,第2部分



同样,让我们​​从推测性结论转向实验。 首先,让我们重复编译器为我们做的事情:

 //Simple COM object var comType = Type.GetTypeFromCLSID(new Guid("435356F9-F33F-403D-B475-1E4AB512FF95")); var autogeneratedBinder = Binder.InvokeMember(CSharpBinderFlags.ResultDiscarded, "Boo", null, typeof(Foo), new CSharpArgumentInfo[2] { CSharpArgumentInfo.Create( CSharpArgumentInfoFlags.UseCompileTimeType, null), CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null) }); var callSite = CallSite<Action<CallSite, Foo, object>>.Create(autogeneratedBinder); while (true) { object instance = Activator.CreateInstance(comType); callSite.Target(callSite, this, instance); } 


我们检查:



泄漏已保留。 公平。 但是是什么原因呢? 在研究了活页夹的代码(我们放在方括号之后)之后,很明显,唯一影响我们对象类型的是约束选项。 也许这不是COM对象的问题,而是绑定器的问题? 没有太多选择,让我们为托管类型引发多个绑定:

 while (true) { object instance = Activator.CreateInstance(typeof(int)); var autogeneratedBinder = Binder.InvokeMember(CSharpBinderFlags.ResultDiscarded, "Boo", null, typeof(Foo), new CSharpArgumentInfo[2] { CSharpArgumentInfo.Create( CSharpArgumentInfoFlags.UseCompileTimeType, null), CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null) }); var callSite = CallSite<Action<CallSite, Foo, object>>.Create(autogeneratedBinder); callSite.Target(callSite, this, instance); } 




哇! 看来我们抓到了他。 这个问题根本就不像最初在我们看来的COM对象那样,只是由于实例的限制,这是唯一的绑定在循环内发生多次的情况。 在所有其他情况下,我都建立了L0缓存并绑定了一次。

结论



内存泄漏



如果您使用包含本机COMTransparentProxy的 动态变量,请不要将它们作为方法参数传递。 如果仍然需要执行此操作,请使用显式强制转换为对象 ,然后编译器将落后于您

错误的
 dynamic com = Activator.CreateInstance(comType); //do some work Marshal.FinalReleaseComObject(com); 


正确地
 dynamic com = Activator.CreateInstance(comType); //do some work Marshal.FinalReleaseComObject((object) com); 


作为额外的预防措施,请尝试尽可能少地实例化此类对象。 适用于.NET Framework的所有版本。 (目前)不是很相关。 NET Core ,因为不支持 动态COM对象。

性能表现



您的利益是尽可能少地发生高速缓存未命中,因为在这种情况下,无需在高级高速缓存中找到合适的规则。 L0高速缓存中的未命中主要是在动态对象的类型与保留的限制不匹配的情况下发生的。

 dynamic com = GetSomeObject(); public object GetSomeObject() { //:      //:         } 


但是,实际上,除非对该函数的调用数以百万计,或者类型的可变性不是异常大,否则您可能不会注意到性能的差异。 L0高速缓存未命中时的开销是这样的, N是类型数:

  • N <10。 如果错过了,请仅迭代现有的L1缓存规则
  • 10 < N <128L1L2缓存的枚举(最多10次和N次迭代)。 创建并填充10个元素的数组
  • N > 128。 遍历L1L2缓存。 创建并填充10和128个元素的数组。 如果您错过了L2缓存,请重新绑定


在第二种和第三种情况下,GC的负载将增加。

结论



不幸的是,我们没有找到导致内存泄漏的真正原因,这需要对绑定器进行单独研究。 幸运的是, WinDbg为进一步研究提供了提示: DLR中发生了一些不好的事情。 第一列是对象数



红利



为什么强制转换为对象可以防止泄漏?
可以将任何类型强制转换为object ,因此操作不再是动态的。

在使用COM对象的字段和方法时,为什么没有泄漏?
这是ExpressionTree用于字段访问的外观:

 .If ( .Call System.Dynamic.ComObject.IsComObject($$arg0) ) { .Return #Label1 { .Dynamic GetMember ComMarks(.Call System.Dynamic2.ComObject.ObjectToComObject($$arg0)) } } 

Source: https://habr.com/ru/post/zh-CN466657/


All Articles