Ich erkläre gleich den Titel des Artikels. Ursprünglich war geplant, einen guten, zuverlässigen Rat zur Beschleunigung der Nutzung von Reflection anhand eines einfachen, aber realistischen Beispiels zu geben. Allerdings stellte sich während des Benchmarkings heraus, dass Reflection nicht so langsam arbeitet, wie ich dachte, und dass LINQ langsamer ist, als ich je befürchtet habe. Zudem habe ich auch noch einen Fehler in meinen Messungen gemacht... Details dieser Lebensgeschichte finden Sie unten und in den Kommentaren. Da das Beispiel recht alltäglich und im Grunde genommen so umgesetzt wurde, wie es normalerweise in der Unternehmenswelt geschieht, ist es eine durchaus interessante, wie ich finde, Demonstration des Lebens geworden: Der Einfluss auf die Geschwindigkeit des Hauptgegenstands des Artikels war aufgrund der äußeren Logik nicht bemerkbar: Moq, Autofac, EF Core und andere „Rahmenwerke“.
Ich begann meine Arbeit inspiriert von diesem Artikel:
Wie zu sehen ist, schlägt der Autor vor, kompilierte Delegaten anstelle von direkten Aufrufen der Methoden der Reflexionstypen zu verwenden, um die Leistung der Anwendung erheblich zu verbessern. Es gibt auch IL-Emission, die man jedoch vermeiden möchte, da dies die aufwändigste Art der Aufgabenerledigung ist, die mit Fehlern verbunden sein kann.
Da ich immer eine ähnliche Meinung zur Geschwindigkeit der Reflexion vertreten habe, wollte ich die Schlussfolgerungen des Autors nicht besonders in Frage stellen.
Ich begegne nicht selten einer naiven Verwendung von Reflection im Enterprise-Bereich. Es wird ein Typ genommen, Informationen über eine Eigenschaft gesammelt und die Methode SetValue aufgerufen. Alle freuen sich, das Wert ist im Ziel-Feld angekommen. Die Leute sind keineswegs dumm – Senior-Entwickler und Teamleiter – sie schreiben ihre Erweiterungen auf Object und basieren auf einer so naiven Vorstellung "universeller" Mapper von einem Typ in einen anderen. Der Grundgedanke ist meist: Wir nehmen alle Felder, nehmen alle Eigenschaften und iterieren über sie. Bei Übereinstimmung der Mitgliedsnamen des Typs wird SetValue ausgeführt. Gelegentlich fangen wir Ausnahmen auf, wenn wir eine Eigenschaft eines der Typen nicht finden können, aber auch hier gibt es einen Ausweg, der die Leistung verbessert: Try/Catch.
Ich habe gesehen, wie Menschen Parser und Mapper neu erfinden, ohne vollständig zu verstehen, wie die zuvor erfundenen Lösungen funktionieren. Ich habe beobachtet, wie sie naive Implementierungen hinter Strategien, Schnittstellen und Injektionen verstecken, als könnte dies die nachfolgenden Probleme rechtfertigen. Solche Implementierungen haben mir missfallen. Tatsächlich habe ich keinen signifikanten Leistungsabfall gemessen und habe, wenn ich die Gelegenheit dazu hatte, die Implementierung einfach auf eine 'optimierte' Version geändert. Daher haben mich die ersten Messungen, von denen unten die Rede ist, ernsthaft überrascht.
Ich denke, viele von Ihnen, die Richter oder andere Ideologen lesen, sind auf die durchaus berechtigte Aussage gestoßen, dass Reflexion im Code ein extrem negatives Phänomen für die Performance einer Anwendung darstellt.
Der Einsatz von Reflection zwingt den CLR dazu, Assemblies auf der Suche nach benötigten Informationen zu durchsuchen, ihre Metadaten abzurufen, sie zu parsen usw. Darüber hinaus führt die Reflection während des Durchsuchens von Sammlungen zur Allocierung eines großen Speicherbereichs. Wenn Speicher verbraucht wird, entfaltet der CLR den Garbage Collector und die Performance leidet. Das sollte mir sehr langsam erscheinen, vertrauen Sie mir. Auch große Speicherkapazitäten moderner Produktionsserver oder Cloud-Maschinen schützen nicht vor hohen Verarbeitungsverzögerungen. Tatsächlich gilt: Je mehr Speicher vorhanden ist, desto wahrscheinlicher ist es, dass Sie die Funktionsweise des Garbage Collectors bemerken. Reflection ist in der Theorie einfach ein überflüssiges rotes Tuch für ihn.
Dennoch verwenden wir sowohl IoC-Container als auch Datenmapper, deren Funktionsweise ebenfalls auf Reflection basiert, ohne dass es üblicherweise Fragen zur Leistung gibt. Nein, nicht weil Dependency Injection und Abstraktheit von Modellen externer, eingeschränkter Kontexte Elemente sind, für die wir die Performance in jedem Fall opfern müssen. Es ist einfacher – sie haben tatsächlich keinen signifikanten Einfluss auf die Performance.
Die meisten verbreiteten Frameworks, die auf Reflexionstechnologie basieren, nutzen verschiedene Tricks, um ihre Leistung zu optimieren. Üblicherweise geschieht dies durch Caching. Dazu gehören Expressions und compilierte Delegates aus Ausdrucksbäumen. Der Auto-Mapper verwendet beispielsweise ein konkurrierendes Wörterbuch, das Typen mit Funktionen verknüpft, die sie ohne den Aufruf von Reflexion ineinander umwandeln können.
Wie wird das erreicht? Im Grunde genommen ähnelt es der Logik, die die Plattform selbst zur Generierung von JIT-Code verwendet. Bei der ersten Methode wird diese kompiliert (und ja, dieser Prozess ist nicht schnell). Bei den nachfolgenden Aufrufen wird die Kontrolle an die bereits kompilierte Methode übergeben, sodass es hier keine signifikanten Leistungseinbußen geben wird.
In unserem Fall können wir ebenfalls JIT-Kompilierung nutzen und anschließend das kompilierte Verhalten mit derselben Leistung wie seine AOT-Analoga verwenden. Expressions werden uns hierbei Unterstützung bieten.
Der zugrunde liegende Grundsatz kann kurz wie folgt zusammengefasst werden:
Es sollte das Endergebnis der Reflexion in Form eines Delegaten zwischengespeichert werden, der die kompilierte Funktion enthält. Alle erforderlichen Objekte mit Typinformationen sollten ebenfalls in den außerhalb der Objekte gespeicherten Feldern Ihres Typs – des Workers – zwischengespeichert werden.
Das macht Sinn. Der gesunde Menschenverstand sagt uns, dass, wenn etwas kompiliert und zwischengespeichert werden kann, wir das tun sollten.
Vorweg kann gesagt werden, dass das Caching in der Arbeit mit Reflexion seine Vorteile hat, selbst wenn die vorgeschlagene Methode der Kompilierung von Ausdrücken nicht verwendet wird. Tatsächlich wiederhole ich hier einfach die Thesen des Autors des Artikels, auf den ich oben verweise.
Jetzt zum Code. Lassen Sie uns ein Beispiel betrachten, das auf meinem kürzlichen Schmerz basiert, mit dem ich in einer ernsthaften Produktion einer bedeutenden Kreditorganisation konfrontiert war. Alle Entitäten sind erfunden, damit niemand darauf kommt.
Es gibt eine Entität. Nennen wir sie Kontakt. Es gibt E-Mails mit einem standardisierten Text, aus denen der Parser und der Hydrator diese Kontakte erstellen. Eine E-Mail kam an, wir haben sie gelesen, in Schlüssel-Wert-Paare zerlegt, einen Kontakt erstellt und in der Datenbank gespeichert.
Das ist ganz einfach. Angenommen, ein Kontakt hat die Attribute Vorname, Nachname, Alter und Telefonnummer. Diese Daten werden dann in der E-Mail übermittelt. Außerdem möchte das Unternehmen, dass die Support-Mitarbeiter neue Schlüssel für die Zuordnung der Eigenschaften der Entität zu den Paaren im Text der E-Mail schnell hinzufügen können. Für den Fall, dass jemand einen Tippfehler im Template gemacht hat oder wenn vor dem Release dringend eine Zuordnung für einen neuen Partner gestartet werden muss, um sich an das neue Format anzupassen. Dann können wir die neue Zuordnung als einfachen Datenfix hinzufügen. Ein praktisches Beispiel.
Wir setzen das um und erstellen Tests. Es funktioniert.
Ich werde den Code nicht anführen: es sind viele Quellcodes entstanden, die am Ende des Artikels auf GitHub verfügbar sind. Sie können sie herunterladen, bis zur Unkenntlichkeit abändern und messen, wie sich das in Ihrem Fall auswirken würde. Ich zeige nur den Code von zwei Vorlagenmethoden, die den schnellen Hydrator von dem langsamen Hydrator unterscheiden.
Die Logik ist wie folgt: Das Template-Methodenmuster erhält Paare, die durch die grundlegende Parser-Logik gebildet werden. Der LINQ-Level fungiert als Parser und die grundlegende Logik des Hydrators, die eine Abfrage an den Datenbankkontext stellt und die Schlüssel mit den vom Parser gelieferten Paaren vergleicht (für diese Funktionen gibt es auch Code ohne LINQ zur Vergleichszwecken). Anschließend werden die Paare an die Hauptmethode der Hydration übergeben, und die Werte der Paare werden den entsprechenden Eigenschaften der Entität zugewiesen.
„Schnell“ (Prefix Fast in Benchmarks):
protected override Contact GetContact(PropertyToValueCorrelation[] correlations)
{
var contact = new Contact();
foreach (var setterMapItem in _proprtySettersMap)
{
var correlation = correlations.FirstOrDefault(x => x.PropertyName == setterMapItem.Key);
setterMapItem.Value(contact, correlation?.Value);
}
return contact;
}
Wie wir sehen, wird eine statische Sammlung mit Property-Settern verwendet – kompilierten Lambdas, die den Entity-Setter aufrufen. Sie werden mit folgendem Code erstellt:
static FastContactHydrator()
{
var type = typeof(Contact);
foreach (var property in type.GetProperties())
{
_proprtySettersMap[property.Name] = GetSetterAction(property);
}
}
private static Action GetSetterAction(PropertyInfo property)
{
var setterInfo = property.GetSetMethod();
var paramValueOriginal = Expression.Parameter(property.PropertyType, "value");
var paramEntity = Expression.Parameter(typeof(Contact), "entity");
var setterExp = Expression.Call(paramEntity, setterInfo, paramValueOriginal).Reduce();
var lambda = (Expression<Action>)Expression.Lambda(setterExp, paramEntity, paramValueOriginal);
return lambda.Compile();
}
Im Großen und Ganzen verständlich. Wir durchlaufen die Eigenschaften, erstellen Delegaten, die Setter aufrufen und speichern. Dann rufen wir sie auf, wenn es nötig ist.
„Langsam“ (Präfix Slow in Benchmarks):
protected override Contact GetContact(PropertyToValueCorrelation[] correlations)
{
var contact = new Contact();
foreach (var property in _properties)
{
var correlation = correlations.FirstOrDefault(x => x.PropertyName == property.Name);
if (correlation?.Value == null)
continue;
property.SetValue(contact, correlation.Value);
}
return contact;
}
Hier durchlaufen wir direkt die Eigenschaften und rufen SetValue direkt auf.
Zur Veranschaulichung und als Referenz habe ich eine naive Methode implementiert, die die Werte ihrer Korrelationspaare direkt in die Felder der Entität schreibt. Präfix – Manual.
Jetzt nehmen wir BenchmarkDotNet und untersuchen die Performance. Und plötzlich… (Spoiler – das ist nicht das richtige Ergebnis, Details folgen unten)

Was sehen wir hier? Methoden, die stolz das Präfix Fast tragen, sind fast bei allen Durchläufen langsamer als die Methoden mit dem Präfix Slow. Dies gilt sowohl für die Allokation als auch für die Ausführungsgeschwindigkeit. Andererseits frisst eine schöne und elegante Implementierung des Mappings, die überall wo möglich die dafür vorgesehenen LINQ-Methoden verwendet, erheblich an der Performance. Der Unterschied ist erheblich. Der Trend ändert sich nicht mit der Anzahl der Durchläufe. Der Unterschied liegt nur im Maßstab. Mit LINQ ist es 4 bis 200 Mal langsamer, der Müll ist in ähnlichen Maßstäben höher.
AKTUALISIERT
Ich konnte meinen Augen nicht trauen, aber was wichtiger ist, weder meinen Augen noch meinem Code glaubte unser Kollege — . Nachdem ich meine Lösung überprüft habe, hat sie exzellent einen Fehler aufgedeckt, den ich aufgrund mehrerer Änderungen bei der Umsetzung von der Anfangs- zur Endphase übersehen hatte. Nach der Behebung des gefundenen Fehlers in den Moq-Einstellungen hat sich alles wieder an seinen Platz gefügt. Die Ergebnisse des Retests zeigen, dass der Einfluss von LINQ auf die Leistung nach wie vor stärker ist als der von Reflexion. Es ist jedoch erfreulich, dass die Arbeit mit der Kompilierung von Expressions nicht umsonst war, und die Ergebnisse sind sowohl bei der Zuweisung als auch bei der Ausführungszeit sichtbar. Der erste Start, wenn die statischen Felder initialisiert werden, ist erwartungsgemäß langsamer bei der „schnellen“ Methode, doch danach ändern sich die Verhältnisse.
Hier ist das Ergebnis des Retests:

Fazit: Bei der Nutzung von Reflexion im Unternehmensumfeld sind besondere Tricks in der Regel nicht erforderlich – LINQ hat einen stärkeren Einfluss auf die Leistung. Dennoch kann in hochbelasteten Methoden, die eine Optimierung erfordern, die Reflexion in Form von Initialisierern und Delegatenkompilatoren beibehalten werden, die dann die „schnelle“ Logik bereitstellen. So können Sie sowohl die Flexibilität der Reflexion als auch die Geschwindigkeit der Anwendung erhalten.
Der Benchmark-Code ist hier verfügbar. Jeder, der will, kann meine Worte überprüfen:
PS: Der Code in den Tests verwendet IoC, während die Benchmarks eine explizite Konstruktion nutzen. Der Grund dafür ist, dass ich in der endgültigen Umsetzung alle Faktoren, die sich auf die Leistung auswirken und die Ergebnisse verzerren könnten, ausgeschlossen habe.
PPS: Danke an den Nutzer für das Aufdecken meines Fehlers in der Moq-Konfiguration, der sich auf die ersten Messungen ausgewirkt hat. Falls jemand von den Lesern genügend Karma hat, gebt ihm bitte einen Like. Der Mensch hat innegehalten, hat sich eingelesen, hat überprüft und auf den Fehler hingewiesen. Das verdient Respekt und Wertschätzung.
PPPS: Danke an den gewissenhaften Leser, der an der Stilistik und Gestaltung Kritik geübt hat. Ich stehe für Einheitlichkeit und Benutzerfreundlichkeit. Die diplomatische Formulierung lässt zu wünschen übrig, aber ich habe die Kritik berücksichtigt. Bitte zum Punkt.
Quelle: habr.com
