Ich erklĂ€re sofort 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. Im Verlauf des Benchmarks stellte sich jedoch heraus, dass Reflection nicht so langsam arbeitet, wie ich dachte, wĂ€hrend LINQ langsamer ist, als ich in meinen schlimmsten AlbtrĂ€umen befĂŒrchtet hatte. AuĂerdem stellte sich heraus, dass ich einen Fehler bei den Messungen gemacht habe⊠Die Details dieser Lebensgeschichte finden Sie im weiteren Verlauf und in den Kommentaren. Da das Beispiel recht alltĂ€glich ist und grundsĂ€tzlich so umgesetzt wird, wie es normalerweise in einem Unternehmen geschieht, handelt es sich meiner Meinung nach um eine interessante Demonstration des Lebens: Der Einfluss auf die Geschwindigkeit der Arbeit des Hauptthemas des Artikels war aufgrund der externen Logik nicht spĂŒrbar: Moq, Autofac, EF Core und Ă€hnliches "Wrapping".
Ich begann meine Arbeit mit dem Eindruck, den dieser Artikel hinterlieĂ:
Wie man sieht, schlÀgt der Autor vor, kompilierte Delegaten anstelle des direkten Zugriffs auf die Methoden der Typen der Reflection zu verwenden, als hervorragenden Weg, die Leistung der Anwendung erheblich zu steigern. Es gibt dort auch IL-Emissionen, aber die möchte ich vermeiden, da dies die arbeitsintensivste Art der Aufgabenerledigung ist, die fehleranfÀllig ist.
Da ich immer der gleichen Meinung ĂŒber die Geschwindigkeit der Reflection war, hatte ich nicht vor, die Schlussfolgerungen des Autors in Frage zu stellen.
Ich stoĂe hĂ€ufig auf naive Anwendungen von Reflection im Unternehmen. Man nimmt einen Typ, holt sich Informationen ĂŒber eine Eigenschaft, ruft die Methode SetValue auf und alle sind glĂŒcklich. Der Wert ist im Zielgebiet angekommen, alle sind zufrieden. Die Menschen sind sehr klug â Senior-Entwickler und Teamleiter â sie schreiben ihre Erweiterungen fĂŒr Object, basierend auf solch einer naiven Implementierung "universelle" Mapper von einem Typ zu einem anderen. Die Vorgehensweise ist meistens: alle Felder und Eigenschaften holen und ĂŒber sie iterieren: Bei Ăbereinstimmung der Namen der Typmitglieder wird SetValue ausgefĂŒhrt. Gelegentlich fangen wir Ausnahmen bei Fehlern, wenn eine Eigenschaft eines der Typen nicht gefunden wurde, aber auch dafĂŒr gibt es eine Lösung, die die Leistung verbessert. Try/Catch.
Ich habe gesehen, wie Menschen Parser und Mapper neu erfinden, ohne vollstĂ€ndig informiert zu sein, wie die vorher erfundenen FahrrĂ€der funktionieren. Ich habe gesehen, wie Menschen ihre naiven Implementierungen hinter Strategien, Interfaces und Injektionen verstecken, als könnte das die nachfolgende Wirrsal entschuldigen. Von solchen Implementierungen habe ich Abstand genommen. TatsĂ€chlich habe ich keine echte Leistungsabnahme gemessen und habe, wo immer möglich, einfach die Implementierung gegen eine âoptimaleâ ausgetauscht, wenn ich dazu kam. Deshalb hat mich die erste Messung, ĂŒber die weiter unten die Rede ist, ernsthaft ĂŒberrascht.
Ich denke, viele von Ihnen haben beim Lesen von Richter oder anderen Ideologen mit einem durchaus berechtigten Argument konfrontiert, dass Reflexion im Code sich Ă€uĂerst negativ auf die Performance der Anwendung auswirkt.
Der Reflexionsaufruf zwingt den CLR, die Assemblies nach der benötigten zu durchsuchen, ihre Metadaten zu laden, sie zu parsen usw. DarĂŒber hinaus fĂŒhrt die Reflexion wĂ€hrend des Durchlaufens von Sequenzen zu einer groĂen Speicherallokation. Wenn wir Speicher verbrauchen, schaltet der CLR den GC ein und schon gibt es Ruckler. Das sollte spĂŒrbar langsam sein, glauben Sie mir. GroĂe Speichermengen moderner Produktionsserver oder Cloud-Maschinen schĂŒtzen nicht vor hohen Latenzen bei der Verarbeitung. TatsĂ€chlich gilt: Je mehr Speicher vorhanden ist, desto wahrscheinlicher werden Sie merken, wie der GC arbeitet. Reflexion ist, theoretisch gesehen, ein zusĂ€tzlicher roter Tuch fĂŒr ihn.
Dennoch nutzen wir sowohl IoC-Container als auch Datenmapper, deren Funktionsweise ebenfalls auf Reflexion basiert. Fragen zur Performance treten dabei normalerweise nicht auf. Nein, nicht weil Dependency Injection und Abstraktion von Modellen des eingeschrĂ€nkten Ă€uĂeren Kontexts so notwendig sind, dass wir in jedem Fall auf Performance verzichten mĂŒssen. Es ist einfacher â sie wirken sich tatsĂ€chlich nicht stark auf die Leistung aus.
Die Sache ist die, dass die gĂ€ngigsten Frameworks, die auf Reflexionstechnologie basieren, verschiedenste Tricks anwenden, um effizienter damit zu arbeiten. In der Regel geschieht dies durch Caching. Ăblicherweise sind das Expressions und aus dem Ausdrucksbaum kompilierte Delegaten. Der gleiche Automapper hĂ€lt ein konkurrierendes Dictionary bereit, das Typen mit Funktionen verknĂŒpft, die bereits ohne Reflexionsaufruf in einander konvertieren können.
Wie wird das erreicht? Im Grunde genommen unterscheidet es sich nicht von der Logik, die die Plattform selbst zur Generierung von JIT-Code verwendet. Bei dem ersten Aufruf einer Methode wird diese kompiliert (und ja, dieser Prozess ist nicht schnell), bei nachfolgenden Aufrufen wird die Kontrolle an die bereits kompilierte Methode ĂŒbergeben, und hier gibt es keine signifikanten LeistungseinbuĂen.
In unserem Fall können wir ebenfalls die JIT-Kompilierung nutzen und dann das kompilierte Verhalten mit derselben Leistung wie seine AOT-Alternativen verwenden. AusdrĂŒcke werden uns in diesem Fall helfen.
Der Grundsatz, um den es hier geht, lÀsst sich kurz zusammenfassen:
Es ist sinnvoll, das Endergebnis der Reflexion in Form eines Delegaten zu cachen, der die kompilierte Funktion enthĂ€lt. Auch alle notwendigen Objekte mit Typinformationen sollten in den auĂerhalb der Objekte gespeicherten Feldern Ihres Typs â des Workers â gecacht werden.
Da macht es Sinn. Der gesunde Menschenverstand sagt uns, dass, wenn etwas kompiliert und zwischengespeichert werden kann, es sinnvoll ist, dies zu tun.
Um vorzugreifen, sollte gesagt werden, dass Caching bei der Arbeit mit Reflexion seine Vorteile hat, selbst wenn die vorgeschlagene Methode zur Kompilierung von AusdrĂŒcken nicht verwendet wird. TatsĂ€chlich wiederhole ich hier einfach die Thesen des Autors des Artikels, auf den ich oben verlinkt habe.
Jetzt zum Code. Lassen Sie uns ein Beispiel betrachten, das auf meinem jĂŒngsten Schmerz basiert, mit dem ich in einer ernsthaften Produktion einer ernsthaften Kreditinstitution konfrontiert wurde. Alle EntitĂ€ten sind fiktiv, damit niemand darauf kommt.
Es gibt eine gewisse EntitĂ€t. Nennen wir sie Kontakt. Es gibt E-Mails mit einem standardisierten Inhalt, aus denen Parser und Hydrator diese Kontakte erstellen. Eine E-Mail kommt an, wir lesen sie, zerlegen sie in SchlĂŒssel-Wert-Paare, erstellen einen Kontakt und speichern ihn in der Datenbank.
Das ist ganz einfach. Angenommen, der Kontakt hat die Eigenschaften Name, Alter und Telefonnummer. Diese Daten werden in der E-Mail ĂŒbermittelt. AuĂerdem möchte das GeschĂ€ft, dass die Supportmitarbeiter neue SchlĂŒssel zur Zuordnung von Eigenschaften der EntitĂ€t zu Paaren im E-Mail-Inhalt schnell hinzufĂŒgen können, falls jemand einen Tippfehler im Template gemacht hat oder wenn vor der Veröffentlichung dringend die Zuordnung eines neuen Partners im neuen Format gestartet werden muss. Dann können wir die neue Zuordnung als einfachen Datenfix hinzufĂŒgen. Das heiĂt, ein praxisnahes Beispiel.
Wir implementieren und erstellen Tests. Es funktioniert.
Ich werde den Code nicht bereitstellen: Es gibt viele Quellcodes, und sie sind ĂŒber den Link am Ende des Artikels auf GitHub verfĂŒgbar. Sie können sie herunterladen, grĂŒndlich testen und messen, wie es sich in Ihrem Fall auswirken wĂŒrde. Ich werde nur den Code von zwei Vorlagenmethoden bereitstellen, die den schnellen Hydrator von dem langsamen unterscheiden.
Die Logik ist wie folgt: Die Vorlagenmethode erhĂ€lt Paare, die durch die grundlegende Logik des Parsers gebildet werden. Das LINQ-Niveau ist der Parser und die grundlegende Logik des Hydrators, die Abfragen an den Datenbankkontext stellt und SchlĂŒssel mit den Paaren des Parsers abgleicht (fĂŒr diese Funktionen gibt es einen Code ohne LINQ zum Vergleich). AnschlieĂend werden die Paare an die Hauptmethode der Hydrierung ĂŒbergeben und die Werte der Parameter werden den entsprechenden Eigenschaften der EntitĂ€t zugewiesen.
"Schnell" (PrÀfix 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 Setter der EntitĂ€t 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 ist es klar. Wir durchlaufen die Eigenschaften, erstellen Delegaten, die die Setter aufrufen und speichern sie. 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 sofort die Eigenschaften und rufen direkt SetValue 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 Leistung. Und plötzlich⊠(Spoiler â das ist nicht das richtige Ergebnis, Details â unten)

Was sehen wir hier? Methoden, die triumphierend das PrĂ€fix Fast tragen, sind fast in allen DurchlĂ€ufen langsamer als die Methoden mit dem PrĂ€fix Slow. Das gilt sowohl fĂŒr die Allokation als auch fĂŒr die Geschwindigkeit. Auf der anderen Seite frisst eine schöne und elegante Implementierung des Mappings mit der Verwendung aller verfĂŒgbaren LINQ-Methoden hingegen stark Leistungsverluste. Der Unterschied ist erheblich. Die Tendenz Ă€ndert sich nicht mit der Anzahl der DurchlĂ€ufe. Der Unterschied liegt nur in den MaĂstĂ€ben. Mit LINQ ist es 4 â 200 Mal langsamer, der MĂŒll ist in etwa den gleichen MaĂstĂ€ben höher.
AKTUALISIERT
Ich konnte meinen Augen nicht trauen, aber was noch wichtiger ist, weder meine Augen noch mein Code wollten unserem Kollegen glauben â . Nachdem er meine Lösung ĂŒberprĂŒft hatte, entdeckte und wies er brillant auf den Fehler hin, den ich aufgrund einer Reihe von Ănderungen bei der Implementierung von der ursprĂŒnglichen zur endgĂŒltigen Version ĂŒbersehen hatte. Nach der Korrektur des gefundenen Fehlers in der Moq-Konfiguration bekamen alle Ergebnisse ihren Platz zurĂŒck. Bei den Ergebnissen des Retests Ă€ndert sich die Haupttendenz nicht â LINQ beeinflusst die Leistung immer noch stĂ€rker als die Reflexion. Es ist jedoch erfreulich, dass die Arbeit mit der Kompilierung von Expressions nicht umsonst ist und das Ergebnis sowohl bei der Allokation als auch bei der AusfĂŒhrungszeit sichtbar ist. Der erste Start, bei dem statische Felder initialisiert werden, ist erwartungsgemÀà langsamer bei der âschnellenâ Methode, aber danach Ă€ndert sich die Situation.
Hier sind die Ergebnisse des Retests:

Fazit: Bei der Verwendung von Reflexion im Unternehmensumfeld ist es nicht erforderlich, besonders zu tricksen â LINQ frisst die Leistung stĂ€rker. Dennoch kann in hochbelasteten Methoden, die eine Optimierung erfordern, Reflexion in Form von Initialisierern und Delegatenkompilierern beibehalten werden, die spĂ€ter die âschnelleâ Logik sicherstellen. So können Sie sowohl die FlexibilitĂ€t der Reflexion als auch die Geschwindigkeit der Anwendung erhalten.
Der Code mit dem Benchmark ist hier verfĂŒgbar. Alle Interessierten können meine Aussagen ĂŒberprĂŒfen:
PS: Der Code in den Tests verwendet IoC, wĂ€hrend in den Benchmarks eine explizite Struktur verwendet wird. Das liegt daran, dass ich in der endgĂŒltigen Implementierung alle Faktoren ausgeschlossen habe, die sich auf die Leistung auswirken und das Ergebnis verschleiern könnten.
PPS: Danke an den Benutzer fĂŒr die Entdeckung meines Fehlers in der Moq-Konfiguration, der die ersten Messungen beeinflusst hat. Wenn einer der Leser genug Karma hat, liken Sie ihn bitte. Die Person hat innegehalten, sich eingelesen, alles ĂŒberprĂŒft und auf den Fehler hingewiesen. Ich halte das fĂŒr respektabel und sympathisch.
PPPS: Danke an den gewissenhaften Leser, der sich mit dem Stil und der Gestaltung beschĂ€ftigt hat. Ich bin fĂŒr Einheitlichkeit und Benutzerfreundlichkeit. Die Diplomatie der PrĂ€sentation lĂ€sst zu wĂŒnschen ĂŒbrig, aber ich habe die Kritik berĂŒcksichtigt. Bitte um VerstĂ€ndnis.
Quelle: habr.com
