Vorspiel
Betrachten Sie den folgenden Code:
Die Signatur der
Marshal.FinalReleaseComObject- Methode lautet wie folgt:
public static int FinalReleaseComObject(Object o)
Wir erstellen ein einfaches COM-Objekt, erledigen einige Arbeiten und geben es sofort frei. Es scheint, dass was schief gehen könnte? Ja, das Erstellen eines Objekts in einer Endlosschleife ist keine gute Vorgehensweise, aber der
GC übernimmt die gesamte Drecksarbeit. Die Realität sieht etwas anders aus:

Um zu verstehen, warum Speicherlecks auftreten, müssen Sie verstehen, wie
Dynamik funktioniert. Es gibt bereits mehrere Artikel zu diesem Thema über Habré, zum Beispiel
diesen , aber sie gehen nicht auf Details der Implementierung ein, daher werden wir unsere eigenen Forschungen durchführen.
Zuerst werden wir den
dynamischen Arbeitsmechanismus im Detail untersuchen, dann werden wir das erworbene Wissen auf ein einziges Bild reduzieren und am Ende werden wir die Gründe für dieses Leck diskutieren und wie es vermieden werden kann. Lassen Sie uns vor dem Eintauchen in den Code die Quelldaten klären: Welche Kombination von Faktoren führt zum Leck?
Die Experimente
Vielleicht ist das Erstellen vieler
nativer COM- Objekte an sich eine schlechte Idee? Lassen Sie uns überprüfen:
Diesmal ist alles gut:

Kehren wir zur ursprünglichen Version des Codes zurück, ändern Sie jedoch den Objekttyp:
Und wieder keine Überraschungen:

Versuchen wir die dritte Option:
Nun, wir sollten definitiv das gleiche Verhalten bekommen! Huh? Nein :(

Ein ähnliches Bild wird angezeigt, wenn Sie com als
Objekt deklarieren oder mit
Managed COM arbeiten . Fassen Sie die experimentellen Ergebnisse zusammen:
- Das Instanziieren nativer COM- Objekte selbst führt nicht zu Lecks - GC bewältigt erfolgreich das Löschen des Speichers
- Bei der Arbeit mit einer verwalteten Klasse treten keine Lecks auf
- Wenn Sie ein Objekt explizit in ein Objekt umwandeln , ist auch alles in Ordnung
Mit Blick auf die Zukunft können wir zum ersten Punkt hinzufügen, dass das Arbeiten mit
dynamischen Objekten (Aufrufen von Methoden oder Arbeiten mit Eigenschaften) für sich genommen ebenfalls keine Lecks verursacht. Die Schlussfolgerung bietet sich an: Ein Speicherverlust tritt auf, wenn wir ein
dynamisches Objekt (ohne "manuelle" Typkonvertierung) übergeben, das
natives COM als Methodenparameter enthält.
Wir müssen tiefer gehen
Es ist Zeit, sich daran zu erinnern,
worum es bei dieser
Dynamik geht :
KurzanleitungC # 4.0 bietet eine neue Art von Dynamik . Dieser Typ vermeidet die statische Typprüfung durch den Compiler. In den meisten Fällen funktioniert es als Objekttyp . Bei der Kompilierung wird davon ausgegangen, dass ein als dynamisch deklariertes Element jede Operation unterstützt. Dies bedeutet, dass Sie nicht darüber nachdenken müssen, woher das Objekt stammt - von der COM-API, einer dynamischen Sprache wie IronPython, mithilfe von Reflection oder von einem anderen Ort. Wenn der Code ungültig ist, werden außerdem zur Laufzeit Fehler ausgegeben.
Wenn beispielsweise die exampleMethod1- Methode im folgenden Code genau einen Parameter enthält, erkennt der Compiler, dass der erste Aufruf der ec.exampleMethod1 (10, 4) -Methode ungültig ist, da sie zwei Parameter enthält. Dies führt zu einem Kompilierungsfehler. Der zweite Methodenaufruf, dynamic_ec.exampleMethod1 (10, 4), wird vom Compiler nicht geprüft, da dynamic_ec daher als dynamisch deklariert wird. Es werden keine Kompilierungsfehler angezeigt. Trotzdem bleibt der Fehler nicht für immer unbemerkt - er wird zur Laufzeit erkannt.
static void Main(string[] args) { ExampleClass ec = new ExampleClass();
class ExampleClass { public ExampleClass() { } public ExampleClass(int v) { } public void exampleMethod1(int i) { } public void exampleMethod2(string str) { } }
Code, der
dynamische Variablen verwendet, wird während der Kompilierung erheblich geändert. Dieser Code:
dynamic com = Activator.CreateInstance(comType); Marshal.FinalReleaseComObject(com);
Wird zu folgendem:
object instance = Activator.CreateInstance(typeFromClsid);
Dabei ist
o__0 die generierte statische Klasse und
p__0 das statische Feld darin:
private class o__0 { public static CallSite<Action<CallSite, Type, object>> p__0; }
Hinweis: Für jede Interaktion mit Dynamic wird ein CallSite-Feld erstellt. Dies ist, wie später zu sehen sein wird, erforderlich, um die Leistung zu optimieren.Beachten Sie, dass keine Erwähnung von
Dynamik übrig bleibt - unser Objekt wird jetzt in einer Variablen vom Typ
Objekt gespeichert. Lassen Sie uns den generierten Code durchgehen. Zunächst wird eine Bindung erstellt, die beschreibt, was und was wir tun:
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) })
Dies ist eine Beschreibung unseres dynamischen Betriebs. Ich
möchte Sie daran erinnern, dass wir eine
dynamische Variable an die
FinalReleaseComObject- Methode übergeben.
- CSharpBinderFlags.ResultDiscarded - Das Ergebnis der Methodenausführung wird in Zukunft nicht mehr verwendet
- "FinalReleaseComObject" - der Name der aufgerufenen Methode
- typeof (Foo) - Operationskontext; die Art des Anrufs
CSharpArgumentInfo - Beschreibung der Bindungsparameter. In unserem Fall:
- CSharpArgumentInfo.Create (CSharpArgumentInfoFlags.UseCompileTimeType | CSharpArgumentInfoFlags.IsStaticType, (string) null) - Beschreibung des ersten Parameters - der Marshal-Klasse: Es ist statisch und sein Typ sollte beim Binden berücksichtigt werden
- CSharpArgumentInfo.Create (CSharpArgumentInfoFlags.None, (string) null) - Beschreibung des Methodenparameters, normalerweise gibt es keine zusätzlichen Informationen.
Wenn es nicht darum geht, eine Methode aufzurufen, sondern beispielsweise eine Eigenschaft von einem
dynamischen Objekt aus
aufzurufen , gibt es nur eine
CSharpArgumentInfo , die das
dynamische Objekt selbst beschreibt.
CallSite ist ein Wrapper über einen dynamischen Ausdruck. Es enthält zwei wichtige Felder für uns:
- öffentliches T-Update
- öffentliches T-Ziel
Aus dem generierten Code geht hervor, dass
Target bei jeder Operation mit Parametern aufgerufen wird, die es beschreiben:
Foo.o__0.p__0.Target((CallSite) Foo.o__0.p__0, typeof (Marshal), instance);
In Verbindung mit der
oben beschriebenen
CSharpArgumentInfo bedeutet dieser Code Folgendes: Sie müssen die FinalReleaseComObject-Methode für die statische Marshal-Klasse mit dem Instanzparameter aufrufen. Zum Zeitpunkt des ersten Aufrufs wird in
Target derselbe Delegat gespeichert wie in
Update . Der
Update- Delegierte ist für zwei wichtige Aufgaben verantwortlich:
- Binden einer dynamischen Operation an eine statische (der Biding-Mechanismus selbst geht über den Rahmen dieses Artikels hinaus)
- Cache-Bildung
Wir interessieren uns für den zweiten Punkt. Hierbei ist zu beachten, dass bei der Arbeit mit einem dynamischen Objekt jedes Mal die Gültigkeit der Operation überprüft werden muss. Dies ist eine ziemlich ressourcenintensive Aufgabe, daher möchte ich die Ergebnisse solcher Überprüfungen zwischenspeichern. Beim Aufrufen einer Methode mit einem Parameter müssen wir Folgendes beachten:
- Der Typ, für den die Methode aufgerufen wird
- Der Objekttyp, der vom Parameter übergeben wird (um sicherzustellen, dass er in den Typ des Parameters umgewandelt werden kann).
- Ist die Operation gültig?
Wenn Sie
Target erneut aufrufen, müssen Sie keine relativ teuren Bindungen ausführen: Vergleichen Sie einfach die Typen und rufen Sie, wenn sie übereinstimmen, die Zielfunktion auf. Um dieses Problem zu lösen, wird für jede dynamische Operation ein
ExpressionTree erstellt, in dem die
Einschränkungen und die
Zielfunktion gespeichert sind, an die der dynamische Ausdruck gebunden wurde.
Es gibt zwei Arten von Funktionen:
- Bindungsfehler : Beispielsweise wird eine Methode für ein dynamisches Objekt aufgerufen, das nicht vorhanden ist, oder ein dynamisches Objekt kann nicht in den Typ des Parameters konvertiert werden, an den es übergeben wird. Anschließend müssen Sie eine Ausnahme wie Microsoft.CSharp.RuntimeBinderException: 'NoSuchMember' auslösen.
- Die Herausforderung ist legal: Führen Sie dann einfach die erforderliche Aktion aus
Dieser
ExpressionTree wird während der Ausführung des
Update- Delegaten gebildet und in
Target gespeichert.
Ziel -
L0- Cache, wir werden später mehr über den Cache sprechen.
Daher speichert
Target den letzten
ExpressionTree , der durch den
Update- Delegaten generiert wurde. Lassen Sie uns sehen, wie diese
Regel wie ein Beispiel für einen
verwalteten Typ aussieht, der an die
Boo- Methode übergeben wird:
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: } }
Der wichtigste Block für uns:
.If ($$arg0 .TypeEqual ConsoleApp12.Foo && $$arg1 .TypeEqual System.Int32)
$$ arg0 und
$$ arg1 sind die Parameter, mit denen
Target aufgerufen wird:
Foo.o__0.p__0.Target((CallSite) Foo.o__0.p__0, <b>this</b>, <b>instance</b>);
In menschlich übersetzt bedeutet dies Folgendes:
Wir haben bereits überprüft, dass Sie
Boo ((Objekt) $$ arg1) sicher aufrufen können, wenn der erste Parameter vom Typ
Foo und der zweite
Int32 ist.
.Return #Label1 { .Block() { .Call $$arg0.Boo((System.Object)((System.Int32)$$arg1)); .Default(System.Object) }
Hinweis: Im Falle eines Bindungsfehlers sieht der Label1-Block ungefähr so aus: .Return #Label1 { .Throw .New Microsoft.CSharp.RuntimeBinderException("NoSuchMember")
Diese Prüfungen werden als
Einschränkungen bezeichnet .
Es gibt zwei Arten von
Einschränkungen : nach Objekttyp und nach spezifischer Instanz des Objekts (das Objekt muss genau gleich sein). Wenn mindestens eine der Einschränkungen fehlschlägt, müssen wir den dynamischen Ausdruck erneut auf Gültigkeit überprüfen. Dazu rufen wir den
Update- Delegaten auf. Nach dem uns bereits bekannten Schema wird er die Bindung mit neuen Typen durchführen und den neuen
ExpressionTree in
Target speichern.
Cache
Wir haben bereits herausgefunden, dass
Target ein
L0-Cache ist . Jedes Mal, wenn
Target aufgerufen wird, gehen wir zuerst die bereits darin gespeicherten Einschränkungen durch. Wenn die Einschränkungen fehlschlagen und eine neue Bindung generiert wird, geht die alte Regel gleichzeitig zu
L1 und
L2 . Wenn Sie in Zukunft den
L0- Cache verpassen, werden die Regeln von
L1 und
L2 durchsucht, bis eine geeignete gefunden wird.
- L1 : Die letzten zehn Regeln, die L0 verlassen haben (direkt in CallSite gespeichert)
- L2 : Die letzten 128 Regeln, die mit einer bestimmten Binder-Instanz erstellt wurden ( CallSiteBinder , die für jede CallSite eindeutig ist).
Jetzt können wir diese Details endlich zu einem Ganzen hinzufügen und in Form eines Algorithmus beschreiben, was passiert, wenn
Foo.Bar (someDynamicObject) aufgerufen wird :
1. Es wird ein Ordner erstellt, der den Kontext und die aufgerufene Methode auf der Ebene ihrer Signaturen speichert
2. Beim ersten Aufruf der Operation wird
ExpressionTree erstellt, in dem Folgendes gespeichert wird:
2.1
Einschränkungen . In diesem Fall sind dies zwei Einschränkungen für die Art der aktuellen Bindungsparameter
2.2
Zielfunktion : entweder
eine Ausnahme auslösen (in diesem Fall ist dies unmöglich, da jede
Dynamik erfolgreich zum Objekt führt) oder einen Aufruf der
Bar- Methode
3. Kompilieren Sie den resultierenden ExpressionTree und führen Sie ihn aus
4. Wenn Sie sich an die Operation erinnern, sind zwei Optionen möglich:
4.1
Einschränkungen funktionierten : Rufen Sie einfach
Bar an4.2
Einschränkungen haben nicht funktioniert : Wiederholen Sie Schritt 2 für neue Bindungsparameter
Am Beispiel des
verwalteten Typs wurde also ungefähr klar, wie
dynamisch von innen funktioniert. Im beschriebenen Fall wird der Cache nie übersehen, da die Typen immer gleich sind *. Daher wird
Update genau einmal aufgerufen, wenn
CallSite initialisiert wird. Dann werden für jeden Anruf nur Einschränkungen überprüft und die Zielfunktion wird sofort aufgerufen. Dies stimmt hervorragend mit unseren Beobachtungen des Gedächtnisses überein: keine Berechnung - keine Lecks.
* Aus diesem Grund generiert der Compiler seine CallSites für jede einzelne: Die Wahrscheinlichkeit, dass der L0-Cache fehlt, ist extrem reduziertEs ist Zeit herauszufinden, wie sich dieses Schema bei
nativen COM- Objekten unterscheidet. Werfen wir einen Blick auf
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: } }
Es ist ersichtlich, dass der Unterschied nur in der zweiten Einschränkung liegt:
.If ($$arg0 .TypeEqual ConsoleApp12.Foo && .Block(System.Object $var1) { $var1 = .Constant<System.WeakReference>(System.WeakReference).Target; $var1 != null && (System.Object)$$arg1 == $var1 })
Wenn wir im Fall von
verwaltetem Code zwei Einschränkungen für den
Objekttyp hatten, sehen wir hier, dass die zweite Einschränkung die Äquivalenz von Instanzen durch
WeakReference überprüft .
Hinweis: Die Instanzbeschränkung wird zusätzlich zu COM-Objekten auch für TransparentProxy verwendetIn der Praxis bedeutet dies, basierend auf unserem Wissen über die Funktionsweise des Caches, dass jedes Mal, wenn wir ein
COM- Objekt in einer Schleife neu erstellen, der
L0- Cache (und auch
L1 / L2) übersehen wird, da die alten Regeln mit Links dort gespeichert werden zu alten Instanzen). Die erste Annahme, die Sie im Kopf fragt, ist, dass der Regel-Cache fließt. Aber der Code dort ist recht einfach und dort ist alles in Ordnung: Die alten Regeln werden korrekt gelöscht. Gleichzeitig verhindert die Verwendung von
WeakReference in
ExpressionTree nicht, dass der
GC unnötige Objekte sammelt.
Der Mechanismus zum Speichern von Regeln im L1-Cache: 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; }
Also, was ist los? Versuchen wir, die Hypothese zu klären: Beim Binden eines
COM- Objekts tritt irgendwo ein Speicherverlust auf.
Experimente, Teil 2
Gehen wir noch einmal von spekulativen Schlussfolgerungen zu Experimenten über. Wiederholen wir zunächst, was der Compiler für uns tut:
Wir prüfen:

Das Leck ist erhalten geblieben. Fair. Aber was ist der Grund? Nach dem Studium des Codes der Ordner (die wir in Klammern lassen) ist klar, dass das einzige, was den Typ unseres Objekts beeinflusst, die Einschränkungsoption ist. Vielleicht handelt es sich nicht um
COM- Objekte, sondern um einen Ordner? Es gibt nicht viel Auswahl. Lassen Sie uns für den
verwalteten Typ eine Mehrfachbindung provozieren:
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); }

Wow! Es scheint, wir haben ihn gefangen. Das Problem liegt überhaupt nicht beim
COM-Objekt , wie es uns anfangs erschien, nur aufgrund der Einschränkungen der Instanz ist dies der einzige Fall, in dem die Bindung innerhalb unserer Schleife viele Male auftritt. In allen anderen Fällen habe ich den
L0-Cache aufgerufen und einmal gebunden.
Schlussfolgerungen
Speicherverlust
Wenn Sie mit
dynamischen Variablen arbeiten, die
natives COM oder
TransparentProxy enthalten , übergeben Sie diese niemals als Methodenparameter. Wenn Sie dies dennoch tun müssen, verwenden Sie die explizite Umwandlung zum
Objekt, und der Compiler bleibt hinter Ihnen zurück
Falsch :
dynamic com = Activator.CreateInstance(comType);
Richtig :
dynamic com = Activator.CreateInstance(comType);
Versuchen Sie als zusätzliche Vorsichtsmaßnahme, solche Objekte so selten wie möglich zu instanziieren. Tatsächlich für alle Versionen von
.NET Framework . (Fürs Erste) ist nicht sehr relevant für.
NET Core , da
dynamische COM- Objekte
nicht unterstützt werden.
Leistung
Es liegt in Ihrem Interesse, dass Cache-Fehler so selten wie möglich auftreten, da in diesem Fall in Caches auf hoher Ebene keine geeignete Regel gefunden werden muss. Fehler im
L0- Cache treten hauptsächlich bei einer Nichtübereinstimmung des Typs des
dynamischen Objekts auf, wobei die Einschränkungen beibehalten werden.
dynamic com = GetSomeObject(); public object GetSomeObject() {
In der Praxis werden Sie den Leistungsunterschied jedoch wahrscheinlich nicht bemerken, es sei denn, die Anzahl der Aufrufe dieser Funktion wird in Millionen gemessen oder die Variabilität der Typen ist nicht ungewöhnlich groß. Die Kosten im Falle eines Fehlschlags im
L0- Cache sind solche,
N ist die Anzahl der Typen:
- N <10. Wenn Sie dies verpassen, durchlaufen Sie nur die vorhandenen L1- Cache-Regeln
- 10 < N <128 . Aufzählung des L1- und L2- Cache (maximal 10 und N Iterationen). Erstellen und Auffüllen eines Arrays mit 10 Elementen
- N > 128. Iterieren Sie über den L1- und L2- Cache. Erstellen und füllen Sie Arrays mit 10 und 128 Elementen. Wenn Sie den L2- Cache verpassen, binden Sie ihn erneut
Im zweiten und dritten Fall erhöht sich die Belastung des GC.
Fazit
Leider haben wir keinen wirklichen Grund für den Speicherverlust gefunden. Dies erfordert eine separate Untersuchung des Binders. Glücklicherweise gibt
WinDbg einen Hinweis für weitere Untersuchungen: Im
DLR passiert etwas Schlimmes. Die erste Spalte gibt die Anzahl der Objekte an

Bonus
Warum verhindert das Gießen auf ein Objekt explizit ein Leck?Jeder Typ kann in ein
Objekt umgewandelt werden , sodass die Operation nicht mehr dynamisch ist.
Warum gibt es keine Lecks bei der Arbeit mit Feldern und Methoden eines COM-Objekts?So sieht
ExpressionTree für den Feldzugriff aus:
.If ( .Call System.Dynamic.ComObject.IsComObject($$arg0) ) { .Return #Label1 { .Dynamic GetMember ComMarks(.Call System.Dynamic2.ComObject.ObjectToComObject($$arg0)) } }