Leistungsoptimierung für .NET (C #) -Anwendungen

Bild

Es gibt viele Artikel mit einer ähnlichen Überschrift, daher werde ich versuchen, alltägliche Themen zu vermeiden. Ich hoffe, dass auch ein sehr erfahrener Entwickler hier etwas Nützliches findet. In diesem Artikel werden nur einfache Optimierungsmechanismen und -ansätze behandelt, mit denen sie mit minimalem Aufwand angewendet werden können. Und diese Änderungen erhöhen nicht die Entropie Ihres Codes. In diesem Artikel wird nicht darauf geachtet, was und wann optimiert werden soll. In diesem Artikel geht es mehr um den Ansatz zum Schreiben von Code im Allgemeinen.

1. ToArray vs ToList


public IEnumerable<string> GetItems() { return _storage.Items.Where(...).ToList(); } 

Zustimmen, ein sehr typischer Code für Industrieprojekte. Aber was ist los mit ihm? Die IEnumerable-Schnittstelle gibt eine Sammlung zurück, die Sie "durchgehen" können. Diese Schnittstelle bedeutet nicht, dass wir Elemente hinzufügen / entfernen können. Dementsprechend muss der LINQ-Ausdruck nicht durch Umwandeln in eine Liste (ToList) beendet werden. In diesem Fall ist das Casting in Array (ToArray) vorzuziehen. Da List ein Wrapper über Array ist und alle zusätzlichen Funktionen dieses Wrappers zur Verfügung stehen, haben wir die Schnittstelle abgeschnitten. Ein Array verbraucht weniger Speicher und der Zugriff auf seine Werte ist schneller. Warum also mehr bezahlen? Einerseits ist diese Optimierung nicht signifikant, da sie "Optimierung bei Spielen" sagt, aber dies ist nicht ganz richtig. Tatsache ist, dass in einer typischen Anwendung, in der Dienste Modelle für die Präsentationsschicht zurückgeben, eine Vielzahl solcher ToList-Aufrufe vorhanden sein kann. In dem oben beschriebenen Beispiel wird die IEnumerable-Schnittstelle nur zur Veranschaulichung eingeführt. Dieser Ansatz ist für alle Fälle relevant, in denen Sie eine Sammlung zurückgeben müssen, die Sie später nicht mehr ändern werden.

Ich sehe einen Kommentar voraus, dass Array und Liste im Fall eines Multithread-Zugriffs auf die Sammlung nicht gleichwertig funktionieren. Das ist tatsächlich so. Wenn Sie als Entwickler jedoch die Möglichkeit eines Multithread-Zugriffs auf eine solche Sammlung mit der Möglichkeit einer Änderung in Betracht ziehen, sind mit hoher Wahrscheinlichkeit weder Array noch List für Sie geeignet.

2. Der Parameter „Dateipfad“ ist nicht immer die beste Wahl für Ihre Methode


Vermeiden Sie bei der Entwicklung einer API Methodensignaturen, die einen Dateipfad als Eingabe erhalten (für die spätere Verarbeitung durch Ihre Methode). Stellen Sie stattdessen die Möglichkeit bereit, ein Array von Bytes an die Eingabe oder als letzten Ausweg für einen Stream zu übergeben. Tatsache ist, dass Ihre Methode im Laufe der Zeit nicht nur auf eine Datei von der Festplatte angewendet werden kann, sondern auch auf eine Datei, die über das Netzwerk übertragen wird, auf eine Datei aus einem Archiv, auf eine Datei aus einer Datenbank, auf eine Datei, deren Inhalt dynamisch im Speicher generiert wird usw. Indem Sie eine Methode mit dem Eingabeparameter "Dateipfad" bereitstellen, verpflichten Sie den Benutzer Ihrer API, die Daten auf der Festplatte zu speichern, bevor Sie sie erneut lesen. Diese bedeutungslose Operation wirkt sich kritisch auf die Leistung aus. Eine Fahrt ist eine extrem langsame Sache. Der Einfachheit halber können Sie eine Methode mit einem Eingabeparameter "Pfad zu einer Datei" bereitstellen. Verwenden Sie jedoch im Inneren immer eine öffentliche überladene Methode mit einem Array von Bytes oder Streams an der Eingabe. Es gibt einen „Marker“, mit dem Sie zusätzliche Schreib- / Path.GetTempPath() Datenträger finden können. Versuchen Sie, diese in Ihrem Projekt mithilfe der Standardmethoden zu finden: Path.GetTempPath() und Path.GetRandomFileName() (von System.IO). Mit hoher Wahrscheinlichkeit werden Sie eine Problemumgehung für das oben genannte Problem oder ähnliches finden.

Ein aufmerksamer und erfahrener Leser wird feststellen, dass das Schreiben auf die Festplatte in einigen Fällen im Gegenteil die Leistung verbessern kann, beispielsweise wenn es sich um sehr große Dateien handelt. Dies ist wahr, es muss berücksichtigt werden, aber ich gehe davon aus, dass dies eine sehr seltene Situation mit einer bestimmten Implementierung ist.

3. Vermeiden Sie die Verwendung von Threads als Parameter und das Rückgabeergebnis Ihrer Methoden


Was ist das Problem hier ... Wenn wir einen Stream von einer „Black Box“ erhalten, müssen wir dessen Zustand berücksichtigen. Das heißt, Ist der Stream offen? Wo ist der Lese- / Schreibmarker? Kann sich sein Status unabhängig von unserem Code ändern? Wenn ein Stream als Basisklasse von Stream deklariert ist, haben wir nicht einmal Informationen darüber, welche Operationen darauf verfügbar sind. All dies wird durch zusätzliche Überprüfungen gelöst, und dies ist zusätzlicher Code und zusätzliche Kosten. Außerdem stieß ich wiederholt auf eine Situation, in der der Entwickler beim Empfang von Streams von einer „obskuren“ Methode lieber auf Nummer sicher ging und Daten von diesem auf einen vollständig kontrollierten neuen lokalen MemoryStream „übertrug“. Der Quelldatenstrom könnte jedoch ziemlich sicher sein. Vielleicht war sogar dies bereits freundlicherweise für das Lesen von MemoryStream vorbereitet. Manchmal kann es den Punkt der Absurdität erreichen - innerhalb einer Methode wird ein Array von Bytes in einen MemoryStream eingefügt, und dieser MemoryStream wird als Ergebnis einer als Basis-Stream deklarierten Methode zurückgegeben. Draußen verwandelt sich dieser Stream in einen neuen MemoryStream, und dann gibt ToArray () ein Array von Bytes zurück, das wir ursprünglich hatten. Genauer gesagt wird es das nächste Exemplar sein. Die Ironie ist, dass innerhalb und außerhalb unserer Methode der Code völlig korrekt ist. Meiner Meinung nach ist dieses Beispiel nicht verrückt, sondern wurde irgendwo im Handelsgesetzbuch gefunden.

Wenn Sie in der Lage sind, "saubere" Daten zu senden / zu empfangen, verwenden Sie daher keine Streams - erstellen Sie keine Traps für diejenigen, die sie verwenden werden. Wenn Ihre Anwendung bereits über Übertragungs- / Rückgabestreams verfügt, analysieren Sie deren Verwendung auf der Grundlage des Vorstehenden.

4. Vererbung von Aufzählungen


Diese Optimierung ist alltäglich, jeder weiß es, auch Studenten. Aber meiner Erfahrung nach wird es äußerst selten verwendet. Enum erbt also standardmäßig von int. Es kann jedoch von einem Byte geerbt werden, das 256 Werte (oder 8 "markierbare" Werte) enthält. Was fast immer die Funktionalität der "mittleren" Aufzählung abdeckt. Eine minimale Änderung des Codes und aller Werte Ihrer Aufzählung beansprucht für immer weniger Speicher. Unten sehen Sie eine Abbildung eines Benchmarks zum Füllen einer Sammlung mit Enum-Werten, die von int und byte geerbt wurden.



Benchmark-Code
 public class CollectEnums { [Params(1000, 10000, 100000, 1000000)] public int N; [Benchmark] public EnumFromInt[] EnumOfInt() { EnumFromInt[] results = new EnumFromInt[N]; for (int i = 0; i < N; i++) { results[i] = EnumFromInt.Value1; } return results; } [Benchmark] public EnumFromByte[] EnumOfByte() { EnumFromByte[] results = new EnumFromByte[N]; for (int i = 0; i < N; i++) { results[i] = EnumFromByte.Value1; } return results; } } public enum EnumFromInt { Value1, Value2 } public enum EnumFromByte: byte { Value1, Value2 } 


5. Noch ein paar Worte zu den Klassen Array und List


Nach der Logik ist das Iterieren über ein Array immer effizienter als das Iterieren über ein "Blatt", da ein "Blatt" ein Wrapper über ein Array ist. Nach der Logik ist "for" immer schneller als "foreach", da "foreach" viele der Aktionen ausführt, die für die Implementierung der IEnumerable-Schnittstelle erforderlich sind. Hier ist alles logisch, aber falsch! Werfen wir einen Blick auf die Benchmark-Ergebnisse:



Benchmark-Code
 public class IterationBenchmark { private List<int> _list; private int[] _array; [Params(100000, 10000000)] public int N; [GlobalSetup] public void Setup() { const int MIN = 1; const int MAX = 10; Random rnd = new Random(); _list = Enumerable.Repeat(0, N).Select(i => rnd.Next(MIN, MAX)).ToList(); _array = _list.ToArray(); } [Benchmark] public int ForList() { int total = 0; for (int i = 0; i < _list.Count; i++) { total += _list[i]; } return total; } [Benchmark] public int ForeachList() { int total = 0; foreach (int i in _list) { total += i; } return total; } [Benchmark] public int ForeachArray() { int total = 0; foreach (int i in _array) { total += i; } return total; } [Benchmark] public int ForArray() { int total = 0; for (int i = 0; i < _array.Length; i++) { total += _array[i]; } return total; } } 


Tatsache ist, dass "foreach" für die Iteration über ein Array keine IEnumerable-Implementierung verwendet. In diesem speziellen Fall wird die am besten optimierte Iteration nach Index durchgeführt, ohne auf Grenzen außerhalb des Arrays zu prüfen, da das Konstrukt "foreach" nicht mit Indizes arbeitet, sodass der Entwickler nicht die Möglichkeit hat, den Code "durcheinander zu bringen". Dies ist die Ausnahme von der Regel. Wenn Sie in einem kritischen Abschnitt des Codes aus Optimierungsgründen die Verwendung von "foreach" durch "for" ersetzt haben, haben Sie sich selbst in den Fuß geschossen. Bitte beachten Sie, dass dies nur für Arrays relevant ist. Es gibt mehrere Zweige in StackOverflow, in denen diese Funktion erläutert wird.

6. Ist das Durchsuchen einer Hash-Tabelle immer gerechtfertigt?


Jeder weiß, dass Hash-Tabellen für die Suche sehr effektiv sind. Aber sie vergessen oft, dass der Preis für eine schnelle Suche eine langsame Ergänzung der Hash-Tabelle ist. Was folgt daraus? Damit die Verwendung der Hash-Tabelle gerechtfertigt ist, muss die Anzahl der Hash-Tabellenelemente mindestens 8 (ungefähr) betragen. Und so war die Anzahl der Suchoperationen mindestens eine Größenordnung größer als die Anzahl der Additionsoperationen. Verwenden Sie andernfalls eine einfachere Sammlung. Die Qualität der Hash-Funktion nimmt eigene Anpassungen an der Effizienz vor, die Bedeutung ändert sich jedoch nicht. In meiner Praxis gab es einen Fall, in dem der größte Engpass im geladenen Code darin bestand, die Dictionary.Add () -Methode aufzurufen. Der Schlüssel war eine normale Saite von kurzer Länge. Sich daran zu erinnern und wurde ein Auslöser für das Schreiben dieses Absatzes. Zur Veranschaulichung ein Beispiel für sehr schlechten Code:

 private static int GetNumber(string numberStr) { Dictionary<string, int> dictionary = new Dictionary<string, int> { {"One", 1}, {"Two", 2}, {"Three", 3} }; dictionary.TryGetValue(numberStr, out int result); return result; } 

Vielleicht passiert etwas Ähnliches in Ihrem Projekt?

7. Einbettungsmethoden


Der Code wird aus zwei Gründen am häufigsten in Methoden unterteilt. Stellen Sie die Wiederverwendung und Zerlegung von Code sicher, wenn eine Aufgabe in mehrere Unteraufgaben unterteilt ist. Für eine Person ist es einfacher. Inlining ist der umgekehrte Zersetzungsprozess, d.h. Der Methodencode ist an der Stelle eingebettet, an der die Methode aufgerufen werden soll. Infolgedessen speichern wir den Aufrufstapel und übergeben Parameter. Ich empfehle in keiner Weise, alles in eine Methode zu schieben. Aber jene Methoden, die wir theoretisch "inline" machen könnten, können mit dem entsprechenden Attribut markiert werden:

 [MethodImpl(MethodImplOptions.AggressiveInlining)] 

Dieses Attribut teilt dem System mit, dass diese Methode eingebettet werden kann. Dies bedeutet nicht, dass die mit diesem Attribut gekennzeichnete Methode unbedingt integriert sein muss. Beispielsweise ist es nicht möglich, rekursive oder virtuelle Methoden einzubetten. Es ist auch erwähnenswert, dass der Einbettungsmechanismus äußerst „empfindlich“ ist. Es gibt viele andere Gründe, warum das System die Einbettung Ihrer Methode verweigert. Das Microsoft-Team, das an .NET Core arbeitet, verwendet dieses Attribut jedoch aktiv. Der Quellcode für .NET Core enthält viele Beispiele für seine Verwendung.

8. Geschätzte Kapazität


Ich (und ich hoffe, die meisten Entwickler auch) haben einen Reflex entwickelt: Ich habe die Sammlung initialisiert - ich habe darüber nachgedacht, ob es möglich ist, die Kapazität dafür festzulegen. Die genaue Anzahl der Sammlungselemente ist jedoch nicht immer im Voraus bekannt. Dies ist jedoch kein Grund, diesen Parameter zu ignorieren. Wenn Sie beispielsweise davon ausgehen, wie viele Elemente in Ihrer Sammlung enthalten sein werden, nehmen Sie an, dass die Anzahl der Elemente verschwommen ist. Dies ist eine Gelegenheit, die Kapazität auf 1000 zu setzen. Eine kleine Theorie, z. B. für Liste standardmäßig, Kapazität = 16, also nur Bei Erreichen von 1000 erstellt das System 1008 (16 + 32 + 64 + 128 + 256 + 512) zusätzliche Kopien der Elemente und erstellt 7 temporäre Arrays, die dem nächsten GC-Aufruf ausgeliefert sind. Das heißt, all diese Arbeit wird verschwendet. Als Kapazität verbietet niemand die Verwendung der Formel. Wenn die Größe Ihrer Sammlung auf ein Drittel der anderen Sammlung geschätzt wird, können Sie die Kapazität auf otherCollection.Count / 3 setzen. Wenn Sie die Kapazität festlegen, sollten Sie den Bereich der möglichen Größe der Sammlung und die Verteilung ihres Werts verstehen. Es besteht immer die Möglichkeit eines Schadens, aber bei richtiger Verwendung bringt Ihnen eine geschätzte Kapazität einen guten Gewinn.

9. Geben Sie immer Ihren Code an.


Verwenden Sie aktiv (auf den ersten Blick optional) C # -Schlüsselwörter wie statisch, const, schreibgeschützt, versiegelt, abstrakt usw. Natürlich, wo sie Sinn machen. Und hier ist die Leistung? Tatsache ist, dass der Code, den er generieren kann, umso optimaler ist, je detaillierter Sie dem Compiler Ihr System beschreiben. Ein aufmerksamer und erfahrener Leser kann feststellen, dass beispielsweise das versiegelte Schlüsselwort keinen Einfluss auf die Leistung hat. Das stimmt, aber in zukünftigen Versionen kann sich alles ändern. Geben Sie dem Compiler und der virtuellen Maschine eine Chance! Holen Sie sich einen Bonus, der viele Fehler bei der missbräuchlichen Verwendung Ihres Codes in der Kompilierungsphase identifiziert. Allgemeine Regel: Je klarer das System beschrieben wird, desto optimaler ist das Ergebnis. Anscheinend auch mit Menschen.

Die wahre Geschichte bestätigt diese Regel, aber wenn Sie Faulheit lesen, können Sie überspringen
Eines Nachts, als er sich mit seinem Hobbyprojekt beschäftigte , stellte er sich die Aufgabe, die Leistung eines Codeabschnitts über ein bestimmtes Niveau zu steigern. Aber diese Seite war kurz und es gab nur wenige Möglichkeiten, was man damit machen sollte. In der Dokumentation habe ich festgestellt, dass ab Version C # 7.2 das Schlüsselwort "readonly" für Strukturen verwendet werden kann. Und in meinem Fall wurden unveränderliche Strukturen verwendet, indem ich ein einzelnes Wort "schreibgeschützt" hinzufügte. Ich bekam, was ich wollte, sogar mit einem Rand! Das System, das wusste, dass meine Strukturen nicht geändert werden sollen, konnte besseren Code für meinen Fall generieren.

10. Verwenden Sie nach Möglichkeit eine Version von .NET für alle Lösungsprojekte


Sie sollten sich bemühen, sicherzustellen, dass alle Assemblys in Ihrer Anwendung zur selben Version von .NET gehören. Dies gilt sowohl für NuGet-Pakete (bearbeitet in packages.config / json) als auch für Ihre eigenen Assemblys (bearbeitet in den Projekteigenschaften). Dies spart RAM und beschleunigt den "Kaltstart", da im Speicher Ihrer Anwendung keine Kopien derselben Bibliotheken für verschiedene Versionen von .NET vorhanden sind. Es ist anzumerken, dass nicht in allen Fällen verschiedene Versionen von .NET Kopien im Speicher generieren. Angenommen, eine Anwendung, die auf derselben Version von .NET basiert, ist immer besser. Dadurch werden auch eine Reihe potenzieller Probleme beseitigt, die außerhalb des Geltungsbereichs dieses Artikels liegen. Die Konsolidierung von Versionen aller von Ihnen verwendeten NuGet-Pakete trägt auch zur Verbesserung der Leistung Ihrer Anwendung bei.

Einige nützliche Werkzeuge


ILSpy ist ein kostenloses Tool, mit dem Sie den wiederhergestellten Assembly-Quellcode anzeigen können. Wenn ich eine Frage dazu habe, welcher .NET-Mechanismus effizienter ist, öffne ich zuerst ILSpy (und nicht Google oder StackOverflow) und sehe dort bereits, wie es implementiert ist. Um beispielsweise herauszufinden, was hinsichtlich der Leistung für den Empfang von Daten über HTTP, die HttpWebRequest- oder die WebClient-Klasse am besten verwendet wird, sehen Sie sich einfach deren Implementierung über ILSpy an. In diesem speziellen Fall ist WebClient ein Wrapper über HttpWebRequest. Die Antwort liegt auf der Hand. .NET-Quellcodes sind keine Angst wert, sie werden von denselben normalen Programmierern geschrieben.

BenchmarkDotNet ist eine kostenlose Bibliothek von Benchmarks. Es gibt eine einfache und intuitive StopWatch (von System.Diagnostics). Aber manchmal reicht es nicht. Da in guter Weise nicht ein einziges Ergebnis, sondern der Durchschnitt mehrerer Vergleiche berücksichtigt werden muss, ist es besser, deren Median zu vergleichen, um den Einfluss des Betriebssystems zu minimieren. Außerdem müssen Sie den "Kaltstart" und die Menge des zugewiesenen Speichers berücksichtigen. Für solch komplexe Tests wurde BenchmarkDotNet erstellt. Diese Bibliothek verwenden .NET Core-Entwickler in offiziellen Tests. Die Bibliothek ist einfach zu bedienen, aber wenn die Autoren diesen Beitrag plötzlich lesen, geben Sie bitte eine bequemere Gelegenheit, die Struktur der Ergebnistabelle zu beeinflussen.

U2U Consult Performance Analyzers ist ein kostenloses Plug-In für Visual Studio, das Tipps zur Verbesserung des Codes in Bezug auf die Leistung bietet. 100% verlassen sich auf den Rat dieses Analysators lohnt sich nicht. Da ich auf eine Situation gestoßen bin, in der mich ein Ratschlag ein wenig überrascht hat und sich nach einer detaillierten Analyse wirklich als falsch herausgestellt hat. Leider geht dieses Beispiel verloren, nehmen Sie also ein Wort. Wenn Sie es jedoch nachdenklich verwenden, ist es ein sehr nützliches Werkzeug. Zum Beispiel wird er vorschlagen, dass es anstelle von myStr.Replace("*", "-") effizienter ist, myStr.Replace('*', '-') . Und die beiden Where-Ausdrücke in LINQ lassen sich besser zu einem kombinieren. Dies sind alles „Optimierungen für Übereinstimmungen“, aber sie sind einfach anzuwenden und führen nicht zu einer Erhöhung des Codes / der Komplexität.

Abschließend


Wenn jede zehnte Person, die den Artikel liest, die oben genannten Ansätze auf ihr aktuelles Projekt (oder einen kritischen Teil davon) anwendet und diese Ansätze auch in Zukunft einhält, können wir gemeinsam den gesamten Wald retten! Wald ??? Das heißt, Die eingesparten Ressourcen von Computersystemen in Form von Strom aus der Verbrennung von Holz bleiben ungenutzt. In diesem Fall ist der „Wald“ nur eine Art Äquivalent. Wahrscheinlich kam eine seltsame Schlussfolgerung heraus, aber ich hoffe, Sie sind von dem Gedanken inspiriert.

PS Update basierend auf Post-Kommentaren


Der Vorteil von ToArray gegenüber ToList ist für .NET Core relevant. Wenn Sie jedoch das alte .NET Framework verwenden, ist ToList wahrscheinlich für Sie vorzuziehen. Das Problem ist, dass in .NET Framework der ToArray-Aufruf selbst erheblich langsamer ist als der ToList-Aufruf. Und diese Verluste können möglicherweise nicht durch schnellere Zugriffe auf Elemente und weniger Array-Speicher ausgeglichen werden. Im Allgemeinen stellte sich dieses Problem als komplizierter heraus, da verschiedene Klassen, die IEnumerable implementieren, unterschiedliche Implementierungen von ToArray und ToList mit unterschiedlichen Effizienzstufen aufweisen können.

Wenn die vom Byte geerbte Aufzählung als Mitglied einer Klasse (Struktur) und nicht separat verwendet wird, wird möglicherweise kein Speicher gespart. Aufgrund der Ausrichtung des belegten Speichers aller Mitglieder der Klasse (Struktur). Dieser Punkt fehlt im Artikel. Trotzdem ist der potenzielle Gewinn besser als sein Fehlen, da neben dem belegten Speicher auch Aufzählungen verwendet werden. Daher ist Absatz 4 immer noch relevant, jedoch mit diesem wichtigen Vorbehalt.

Vielen Dank an KvanTTT und epetrukhin für konstruktive Kommentare zu diesen Themen.

Wie Taritsyn bemerkte, besteht in der JIT-Kompilierungsphase noch eine Optimierung für das Schlüsselwort "versiegelt". Dies bestätigt jedoch nur alle Thesen des 9. Absatzes.

Es scheint, dass alle konstruktiven Kommentare berücksichtigt wurden. Ich bin sehr zufrieden mit diesen Kommentaren. Da ich selbst als Autor ein Feedback erhalten habe und auch etwas Neues für mich gelernt habe.

Source: https://habr.com/ru/post/de466931/


All Articles