Optimierung der Speicherbereinigung in einem hoch geladenen .NET-Dienst

    Täglich arbeiten bei Pyrus Zehntausende von Mitarbeitern aus mehreren tausend Organisationen weltweit. Wir betrachten die Reaktionsfähigkeit des Dienstes (Geschwindigkeit der Bearbeitung von Anfragen) als einen wichtigen Wettbewerbsvorteil, da dies die Benutzererfahrung direkt beeinflusst. Die Schlüsselmetrik für uns ist der „Prozentsatz langsamer Abfragen“. Bei der Untersuchung des Verhaltens haben wir festgestellt, dass auf den Anwendungsservern einmal pro Minute Pausen von etwa 1000 ms Länge auftreten. In diesen Intervallen antwortet der Server nicht und es entsteht eine Warteschlange mit mehreren Dutzend Anforderungen. In diesem Artikel wird die Suche nach Ursachen und Beseitigung von Engpässen bei der Garbage Collection in der Anwendung erörtert.



    Moderne Programmiersprachen lassen sich in zwei Gruppen einteilen. In Sprachen wie C / C ++ oder Rust wird die manuelle Speicherverwaltung verwendet, sodass Programmierer mehr Zeit mit dem Schreiben von Code, dem Verwalten der Lebensdauer von Objekten und dem anschließenden Debuggen verbringen. Gleichzeitig sind Fehler, die auf eine unsachgemäße Verwendung des Arbeitsspeichers zurückzuführen sind, am schwierigsten zu beheben. Daher wird die modernste Entwicklung in Sprachen mit automatischer Speicherverwaltung durchgeführt. Dazu gehören beispielsweise Java, C #, Python, Ruby, Go, PHP, JavaScript usw. Programmierer sparen Entwicklungszeit, aber Sie müssen zusätzliche Zeit aufwenden, die das Programm regelmäßig für die Speicherbereinigung ausgibt. Dadurch wird Speicherplatz frei, der von Objekten belegt wird, zu denen im Programm keine Links mehr vorhanden sind. In kleinen Programmen ist diese Zeit vernachlässigbar,

    Pyrus-Webserver werden auf der .NET-Plattform ausgeführt, die die automatische Speicherverwaltung verwendet. Die meisten Müllsammlungen sind "stop the world", d. H. Zum Zeitpunkt ihrer Arbeit stoppen alle Threads (Threads) der Anwendung. Nicht blockierende (Hintergrund-) Assemblys stoppen tatsächlich auch alle Threads, jedoch für einen sehr kurzen Zeitraum. Während des Thread-Blockierens verarbeitet der Server keine Anforderungen, vorhandene Anforderungen frieren ein und neue werden zur Warteschlange hinzugefügt. Infolgedessen werden Anforderungen, die zum Zeitpunkt der Speicherbereinigung verarbeitet wurden, direkt verlangsamt, und Anforderungen werden unmittelbar nach Abschluss der Speicherbereinigung aufgrund der angesammelten Warteschlangen langsamer verarbeitet. Dies verschlechtert die Metrik "Prozentsatz langsamer Abfragen".

    Bewaffnet mit dem aktuellen Konrad Kokosa: Pro .NET Memory Management- Buch (darüber, wie wir seine erste Kopie in 2 Tagen nach Russland gebracht haben, können Sie einen separaten Beitrag schreiben), der sich ganz dem Thema Speicherverwaltung in .NET widmet, haben wir begonnen, das Problem zu untersuchen.

    Messung


    Für die Profilerstellung des Pyrus-Webservers haben wir das PerfView-Dienstprogramm ( https://github.com/Microsoft/perfview ) verwendet, das für die Profilerstellung von .NET-Anwendungen optimiert wurde. Das Dienstprogramm basiert auf der ETW-Engine (Event Tracing for Windows) und hat nur minimale Auswirkungen auf die Leistung der Anwendung mit Profil, sodass sie auf einem Kampfserver verwendet werden kann. Darüber hinaus hängt die Auswirkung auf die Leistung davon ab, welche Arten von Ereignissen und welche Informationen erfasst werden. Wir sammeln nichts - die Anwendung funktioniert wie gewohnt. PerfView erfordert außerdem weder eine Neukompilierung noch einen Neustart der Anwendung.

    Führen Sie den PerfView-Trace mit der Option / GCCollectOnly aus (Tracezeit 1,5 Stunden). In diesem Modus werden nur Garbage Collection-Ereignisse erfasst, und die Leistung wird nur minimal beeinträchtigt. Schauen wir uns den Speichergruppen- / GCStats-Trace-Bericht an und darin eine Zusammenfassung der Garbage Collector-Ereignisse:



    Hier sehen wir gleich mehrere interessante Indikatoren:
    • Die durchschnittliche Build-Pausenzeit in der 2. Generation beträgt 700 Millisekunden und die maximale Pause beträgt ungefähr eine Sekunde. Diese Abbildung zeigt den Zeitpunkt, zu dem alle Threads in der .NET-Anwendung beendet werden. Insbesondere wird diese Pause allen verarbeiteten Anforderungen hinzugefügt.
    • Die Anzahl der Baugruppen der 2. Generation ist vergleichbar mit der 1. Generation und liegt geringfügig unter der Anzahl der Baugruppen der 0. Generation.
    • In der Spalte Induced sind 53 Baugruppen der 2. Generation aufgeführt. Die induzierte Assembly ist das Ergebnis eines expliziten Aufrufs von GC.Collect (). Wir haben in unserem Code keinen einzigen Aufruf dieser Methode gefunden, was bedeutet, dass einige der von unserer Anwendung verwendeten Bibliotheken schuld sind.

    Lassen Sie uns die Beobachtung über die Anzahl der Müllsammlungen erklären. Die Idee, Objekte durch ihre Lebensdauer zu teilen, basiert auf der Generationshypothese): Ein erheblicher Teil der erstellten Objekte stirbt schnell ab und der größte Teil lebt lange (mit anderen Worten, nur wenige Objekte mit einer „durchschnittlichen“ Lebensdauer). In diesem Modus ist der .NET-Garbage-Collector gesperrt, und in diesem Modus sollten die Assemblys der zweiten Generation viel kleiner sein als die der 0. Generation. Das heißt, für den optimalen Betrieb des Müllsammlers müssen wir die Arbeit unserer Anwendung auf die Generationshypothese abstimmen. Formulieren wir die Regel wie folgt: Objekte müssen entweder schnell sterben, nicht für die ältere Generation leben, oder dafür leben und dort für immer leben. Diese Regel gilt auch für andere Plattformen, die die automatische Speicherverwaltung mit Generationsunterscheidung verwenden, z. B. Java.

    Die Daten, die für uns von Interesse sind, können aus einer anderen Tabelle im GCStats-Bericht extrahiert werden:



    In einigen Fällen versucht eine Anwendung, ein großes Objekt zu erstellen (in .NET Framework werden im LOH - Large Object Heap Objekte mit einer Größe von> 85.000 Byte erstellt) und muss auf den Abschluss der Assembly der zweiten Generation warten, die parallel im Hintergrund ausgeführt wird. Diese Pausen des Allokators sind nicht so kritisch wie die Pausen des Garbage Collectors, da sie nur einen Thread betreffen. Davor haben wir die Version von .NET Framework 4.6.1 verwendet und in Version 4.7.1 hat Microsoft den Garbage Collector finalisiert. Jetzt können Sie während der Hintergrunderstellung der 2. Generation Speicher im Large Object Heap zuweisen: https://docs.microsoft.com / ru-ru / dotnet / framework / whats-new / # common-language-runtime-clr Aus diesem Grund
    haben wir zu diesem Zeitpunkt auf die neueste Version 4.7.2 aktualisiert.

    Builds der 2. Generation


    Warum haben wir so viele Builds der älteren Generation? Die erste Annahme ist, dass wir einen Speicherverlust haben. Schauen wir uns zum Testen dieser Hypothese die Größe der zweiten Generation an (wir haben die Überwachung der entsprechenden Leistungsindikatoren in Zabbix eingerichtet). Aus den Diagrammen der 2. Generation für 2 Pyrus-Server geht hervor, dass die Größe zunächst zunimmt (hauptsächlich aufgrund des Füllens der Caches), sich dann aber stabilisiert (große Fehler im Diagramm - regelmäßiger Neustart des Webdienstes zur Aktualisierung der Version):



    Dies bedeutet, dass es keine merklichen Speicherverluste gibt, das heißt, dass eine große Anzahl von Baugruppen der zweiten Generation aus einem anderen Grund auftritt. Die nächste Hypothese ist, dass es viel Speicherverkehr gibt, d. H. Viele Objekte fallen in die 2. Generation und viele Objekte sterben dort. Um solche Objekte in PerfView zu finden, gibt es den / GCOnly-Modus. Beachten wir in den Trace-Berichten die Stapel mit den Todesfällen von Objekten der 2. Generation (Coarse Sampling), die eine Auswahl von Objekten enthalten, die in der 2. Generation absterben, sowie die Aufrufstapel der Orte, an denen diese Objekte erstellt wurden. Hier sehen wir die folgenden Ergebnisse:



    Nachdem wir die Zeile geöffnet haben, sehen wir im Inneren den Aufrufstapel der Stellen im Code, an denen Objekte erstellt werden, die der 2. Generation entsprechen. Unter ihnen:
    • System.Byte [] Wenn Sie nach innen schauen, werden wir feststellen, dass mehr als die Hälfte Puffer für die Serialisierung in JSON sind:



    • Slot [System.Int32] [] (dies ist Teil der HashSet-Implementierung), System.Int32 [] usw. Dies ist unser Code, der Client-Caches berechnet - die Verzeichnisse, Formulare, Listen, Freunde usw., die dieser Benutzer sieht und die in seinem Browser oder in seiner mobilen Anwendung zwischengespeichert sind:





    Interessanterweise sind die Puffer für JSON- und für Computing-Client-Caches alle temporäre Objekte, die auf derselben Anforderung basieren. Warum leben sie bis zur 2. Generation? Beachten Sie, dass alle diese Objekte Arrays von ziemlich großer Größe sind. Und bei einer Größe von> 85.000 Bytes wird der Speicher für sie im Large Object Heap reserviert, der nur zusammen mit der 2. Generation gesammelt wird.

    Um dies zu überprüfen, öffnen Sie den Abschnitt "GC-Heap-Zuweisung ignorieren (Grobabtastung)" in den Ergebnissen von perfview / GCOnly. Dort sehen wir die Zeile LargeObject, in der PerfView die Erstellung großer Objekte gruppiert, und darin sehen wir alle Arrays, die wir in der vorherigen Analyse gesehen haben. Wir erkennen die Hauptursache der Probleme mit dem Garbage Collector an: Wir erstellen viele temporäre große Objekte.





    Änderungen im Pyrus-System


    Basierend auf den Messergebnissen haben wir die Hauptbereiche für die weitere Arbeit identifiziert: den Kampf gegen große Objekte bei der Berechnung von Client-Caches und die Serialisierung in JSON. Es gibt verschiedene Lösungen für dieses Problem:
    • Das Einfachste ist, keine großen Objekte zu erstellen. Wenn beispielsweise in sequentiellen Datentransformationen A-> B-> C ein großer Puffer B verwendet wird, können diese Transformationen manchmal kombiniert werden, indem sie in A-> C umgewandelt werden und die Erstellung von Objekt B eliminiert wird. Diese Option ist jedoch nicht immer anwendbar das einfachste und effektivste.
    • Pool von Objekten. Anstatt ständig neue Objekte zu erstellen und sie wegzuwerfen und den Müllsammler zu laden, können wir eine Sammlung kostenloser Objekte speichern. Im einfachsten Fall nehmen wir ein neues Objekt aus dem Pool oder erstellen ein neues Objekt, wenn der Pool leer ist. Wenn wir das Objekt nicht mehr benötigen, legen wir es in den Pool zurück. Ein gutes Beispiel ist ArrayPool in .NET Core, das auch in .NET Framework als Teil des System.Buffers Nuget-Pakets verfügbar ist.
    • Verwenden Sie kleine Objekte anstelle von großen.

    Betrachten wir beide Fälle von großen Objekten getrennt - das Berechnen von Client-Caches und das Serialisieren in JSON.

    Berechnung des Client-Cache


    Der Pyrus-Webclient und die mobilen Anwendungen speichern die dem Benutzer zur Verfügung stehenden Daten (Projekte, Formulare, Benutzer usw.) im Cache. Das Caching dient zur Beschleunigung der Arbeit und ist auch für die Arbeit im Offlinemodus erforderlich. Caches werden auf dem Server berechnet und auf den Client übertragen. Sie sind für jeden Benutzer individuell, da sie von seinen Zugriffsrechten abhängen, und werden häufig aktualisiert, wenn beispielsweise Verzeichnisse geändert werden, auf die er Zugriff hat.

    Daher werden regelmäßig viele Client-Cache-Berechnungen auf dem Server durchgeführt und viele temporäre kurzlebige Objekte erstellt. Wenn der Benutzer eine große Organisation ist, kann er auf viele Objekte zugreifen bzw. Client-Caches für ihn sind groß. Aus diesem Grund haben wir die Speicherzuweisung für große temporäre Arrays im Large Object Heap gesehen.

    Analysieren wir die vorgeschlagenen Optionen, um die Erstellung großer Objekte zu vermeiden:
    • Komplette Entsorgung großer Objekte. Dieser Ansatz ist nicht anwendbar, da Datenvorbereitungsalgorithmen unter anderem das Sortieren und Vereinigen von Mengen verwenden und temporäre Puffer erfordern.
    • Einen Pool von Objekten verwenden. Dieser Ansatz hat Schwierigkeiten:
      • Die Vielfalt der verwendeten Sammlungen und die Arten der darin enthaltenen Elemente: HashSet, List und Array werden verwendet (letztere 2 können kombiniert werden). Int32, Int64 sowie alle Arten von Datenklassen werden in Auflistungen gespeichert. Für jeden verwendeten Typ benötigen Sie einen eigenen Pool, in dem auch Sammlungen unterschiedlicher Größe gespeichert werden.
      • Schwierige Lebensdauer der Sammlungen. Um den Pool nutzen zu können, müssen Objekte nach der Verwendung wieder in den Pool zurückgebracht werden. Dies ist möglich, wenn das Objekt in einer Methode verwendet wird. In unserem Fall ist die Situation jedoch komplizierter, da viele große Objekte zwischen Methoden ausgetauscht, in Datenstrukturen platziert, auf andere Strukturen übertragen usw. werden.
      • Implementierung. Es gibt ArrayPool von Microsoft, aber wir brauchen immer noch List und HashSet. Wir haben keine passende Bibliothek gefunden, daher müssten wir die Klassen selbst implementieren.
    • Verwendung kleiner Gegenstände. Ein großes Array kann in mehrere kleine Teile unterteilt werden. Ich lade den Heap für große Objekte nicht, sondern erstelle ihn in der 0. Generation und gehe dann den Standardpfad in der 1. und 2. Generation. Wir hoffen, dass sie nicht bis zur 2. Generation leben, sondern vom Müllsammler in der 0. oder im Extremfall in der 1. Generation eingesammelt werden. Der Vorteil dieses Ansatzes besteht darin, dass die Änderungen an vorhandenem Code minimal sind. Schwierigkeiten:
      • Implementierung. Wir haben keine geeigneten Bibliotheken gefunden, daher müssten wir die Klassen selbst schreiben. Das Fehlen von Bibliotheken ist verständlich, da das Szenario „Sammlungen, die keinen Heap für große Objekte laden“ einen sehr engen Bereich darstellt.

    Wir haben beschlossen, den dritten Weg zu gehen und unser Fahrrad zu erfinden , um List und HashSet zu schreiben, die den Large Object Heap nicht laden.

    Stückliste


    Unsere ChunkedList implementiert Standardschnittstellen einschließlich IListAus diesem Grund sind minimale Änderungen am vorhandenen Code erforderlich. Ja, und die von uns verwendete Newtonsoft.Json-Bibliothek kann sie automatisch serialisieren, da sie IEnumerable implementiert:

    public sealed class ChunkedList : IList, ICollection, IEnumerable, IEnumerable, IList, ICollection, IReadOnlyList, IReadOnlyCollection
    {

    Standard List ListeFolgende Felder stehen zur Verfügung: ein Array für Elemente und die Anzahl der gefüllten Elemente. In der ChunkedListEs gibt ein Array von Elementarrays, die Anzahl der vollständig ausgefüllten Arrays und die Anzahl der Elemente im letzten Array. Jedes der Arrays von Elementen mit weniger als 85.000 Bytes:


    private T[][] chunks;
    private int currentChunk;
    private int currentChunkSize;

    Seit ChunkedListDa es ziemlich kompliziert ist, haben wir detaillierte Tests geschrieben. Jede Operation muss in mindestens zwei Modi getestet werden: in "small" (klein), wenn die gesamte Liste in ein Teil mit einer Größe von bis zu 85.000 Byte passt, und in "large" (groß), wenn sie aus mehreren Teilen besteht. Darüber hinaus sind die Szenarien für Methoden, die die Größe ändern (z. B. Hinzufügen), noch größer: "Klein" -> "Klein", "Klein" -> "Groß", "Groß" -> "Groß", "Groß" -> " klein. " Hier gibt es einige verwirrende Grenzfälle, die Unit-Tests gut machen.

    Die Situation wird dadurch vereinfacht, dass einige der Methoden der IList-Schnittstelle nicht verwendet werden und weggelassen werden können (z. B. Einfügen, Entfernen). Ihre Implementierung und Erprobung wäre ziemlich aufwändig. Darüber hinaus wird das Schreiben von Komponententests dadurch vereinfacht, dass wir keine neue Funktionalität, ChunkedList, brauchen sollte sich wie List verhalten. Das heißt, alle Tests sind wie folgt organisiert: Erstellen Sie eine Liste und Chunkedlistführen wir die gleichen Operationen an ihnen durch und vergleichen die Ergebnisse.

    Wir haben die Leistung mithilfe der BenchmarkDotNet-Bibliothek gemessen, um sicherzustellen, dass wir unseren Code beim Wechsel von List nicht wesentlich verlangsamen auf ChunkedList. Testen wir zum Beispiel das Hinzufügen von Elementen zur Liste:

    [Benchmark]
    public ChunkedList ChunkedList()
    {
    	var list = new ChunkedList();
    	for (int i = 0; i < N; i++)
    		list.Add(i);
    	return list;
    }

    Und der gleiche Test mit Listzum Vergleich. Ergebnisse beim Hinzufügen von 500 Elementen (alles passt in ein Array):
    Methode Gemein Fehler Stddev Gen 0/1 k Op Gen 1 / 1k Op Gen 2 / 1k Op Belegter Speicher / Op
    Standardliste 1,415 wir 0,0149 us 0,0140 us 0,6847 0,0095 - 4,21 KB
    Chunkedlist 3,728 us 0,0238 us 0,0222 us 0,6943 0,0076 - 4,28 KB

    Ergebnisse beim Hinzufügen von 50.000 Elementen (aufgeteilt in mehrere Arrays):
    Methode Gemein Fehler Stddev Gen 0/1 k Op Gen 1 / 1k Op Gen 2 / 1k Op Belegter Speicher / Op
    Standardliste 146,273 us 3.1466 us 4,8053 uns 124.7559 124.7559 124.7559 513,23 KB
    Chunkedlist 287.687 us 1,4630 uns 1,2969 uns 41.5039 20.5078 - 256,75 KB

    Detaillierte Beschreibung der Spalten in den Ergebnissen
    BenchmarkDotNet=v0.11.4, OS=Windows 10.0.17763.379 (1809/October2018Update/Redstone5)
    Intel Core i7-8700K CPU 3.70GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
      [Host]     : .NET Framework 4.7.2 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.3324.0
      DefaultJob : .NET Framework 4.7.2 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.3324.0
    // * Hints *
    Outliers
      ListAdd.StandardList: Default -> 2 outliers were removed
      ListAdd.ChunkedList: Default  -> 1 outlier  was  removed
    // * Legends *
      Mean                : Arithmetic mean of all measurements
      Error               : Half of 99.9% confidence interval
      StdDev              : Standard deviation of all measurements
      Gen 0/1k Op         : GC Generation 0 collects per 1k Operations
      Gen 1/1k Op         : GC Generation 1 collects per 1k Operations
      Gen 2/1k Op         : GC Generation 2 collects per 1k Operations
      Allocated Memory/Op : Allocated memory per single operation (managed only, inclusive, 1KB = 1024B)
      1 us                : 1 Microsecond (0.000001 sec)


    Wenn Sie sich die Spalte 'Mittelwert' ansehen, in der die durchschnittliche Testausführungszeit angezeigt wird, können Sie feststellen, dass unsere Implementierung nur um das 2 bis 2,5-fache langsamer als der Standard ist. Wenn man bedenkt, dass Operationen mit Listen im realen Code nur einen kleinen Teil aller durchgeführten Aktionen ausmachen, wird dieser Unterschied unwesentlich. Die Spalte 'Gen 2 / 1k op' (die Anzahl der Baugruppen der 2. Generation für 1000 Testläufe) zeigt jedoch, dass wir das Ziel erreicht haben: Mit einer großen Anzahl von Elementen erzeugt die ChunkedList in der 2. Generation keinen Müll, was unsere Aufgabe war.

    Teilesatz


    Ähnlich wie ChunkedHashSet implementiert die ISet-Schnittstelle. Beim Schreiben eines ChunkedHashSetWir haben die in ChunkedList bereits implementierte Logik des Zerlegens in kleine Teile wieder verwendet. Dafür haben wir eine fertige Implementierung von HashSet genommenaus der .NET-Referenzquelle, die unter der MIT-Lizenz verfügbar ist, und die darin enthaltenen Arrays durch ChunkedLists ersetzt.

    In Unit-Tests verwenden wir auch den gleichen Trick wie für Listen: Wir vergleichen das Verhalten von ChunkedHashSet mit Referenz-Hashset.

    Schließlich Leistungstests. Die Hauptoperation, die wir verwenden, ist die Vereinigung von Mengen, weshalb wir sie testen:

    public ChunkedHashSet ChunkedHashSet(int[][] source)
    {
    	var set = new ChunkedHashSet();
    	foreach (var arr in source)
    		set.UnionWith(arr);
    	return set;
    }

    Und genau der gleiche Test für das Standard-HashSet. Erster Test für kleine Sets:

    var source = new int[][] {
    	Enumerable.Range(0, 300).ToArray(),
    	Enumerable.Range(100, 600).ToArray(),
    	Enumerable.Range(300, 1000).ToArray(),
    }

    Methode Gemein Fehler Stddev Gen 0/1 k Op Gen 1 / 1k Op Gen 2 / 1k Op Belegter Speicher / Op
    StandardHashSet 30.16 uns 0,1046 uns 0,0979 us 9.3079 1.6785 - 57,41 KB
    ChunkedHashSet 73,54 wir 0,5919 uns 0,5247 us 9,5215 1,5869 - 58,84 KB

    Der zweite Test für große Mengen, die ein Problem mit einer Reihe großer Objekte verursacht haben:

    var source = new int[][] {
    	Enumerable.Range(0, 30000).ToArray(),
    	Enumerable.Range(10000, 60000).ToArray(),
    	Enumerable.Range(30000, 100000).ToArray(),
    }

    Methode Gemein Fehler Stddev Gen 0/1 k Op Gen 1 / 1k Op Gen 2 / 1k Op Belegter Speicher / Op
    StandardHashSet 3,031.30 uns 32.0797 uns 28.4378 uns 699,2188 667,9688 664.0625 4718,23 KB
    ChunkedHashSet 7.189,66 uns 25.6319 uns 23.9761 uns 539.0625 265,6250 7.8125 3280.71 KB

    Die Ergebnisse ähneln den Auflistungen. ChunkedHashSet ist um das 2-2,5-fache langsamer, aber gleichzeitig lädt es bei großen Sets die 2. Generation 2 Größenordnungen weniger.

    Serialisierung in JSON


    Der Pyrus-Webserver bietet mehrere APIs mit unterschiedlicher Serialisierung. Wir haben festgestellt, dass große Objekte in der von Bots verwendeten API und im Synchronisierungsdienstprogramm (im Folgenden als öffentliche API bezeichnet) erstellt wurden. Beachten Sie, dass die API grundsätzlich eine eigene Serialisierung verwendet, die von diesem Problem nicht betroffen ist. Wir haben darüber im Artikel https://habr.com/de/post/227595/ im Abschnitt „2. Sie wissen nicht, wo sich der Engpass Ihrer Anwendung befindet. " Das heißt, die Haupt-API funktioniert bereits einwandfrei, und das Problem trat in der öffentlichen API auf, als die Anzahl der Anforderungen und die Datenmenge in den Antworten zunahm.

    Optimieren wir die öffentliche API. Am Beispiel der Haupt-API wissen wir, dass Sie im Streaming-Modus eine Antwort an den Benutzer zurückgeben können. Das heißt, Sie müssen keine Zwischenpuffer erstellen, die die gesamte Antwort enthalten, sondern die Antwort sofort in den Stream schreiben.

    Bei näherer Betrachtung haben wir festgestellt, dass wir beim Serialisieren der Antwort einen temporären Puffer für das Zwischenergebnis erstellen ('content' ist ein Byte-Array, das JSON in UTF-8-Codierung enthält):

    var serializer = Newtonsoft.Json.JsonSerializer.Create(...);
    byte[] content;
    var sw = new StreamWriter(new MemoryStream(), new UTF8Encoding(false));
    using (var writer = new Newtonsoft.Json.JsonTextWriter(sw))
    {
    	serializer.Serialize(writer, result);
    	writer.Flush();				
    	content = ms.ToArray();
    }

    Mal sehen, wo der Inhalt verwendet wird. Aus historischen Gründen basiert die öffentliche API auf WCF, für das XML das Standard-Anforderungs- und Antwortformat ist. In unserem Fall hat die XML-Antwort ein einzelnes 'Binary'-Element, in das JSON geschrieben ist, das in Base64 codiert ist:

    public class RawBodyWriter : BodyWriter
    {
    	private readonly byte[] _content;
    	public RawBodyWriter(byte[] content)
    		: base(true)
    	{
    		_content = content;
    	}
    	protected override void OnWriteBodyContents(XmlDictionaryWriter writer)
    	{
    		writer.WriteStartElement("Binary");
    		writer.WriteBase64(_content, 0, _content.Length);
    		writer.WriteEndElement();
    	}
    }

    Beachten Sie, dass hier kein temporärer Puffer benötigt wird. JSON kann sofort in den XmlWriter-Puffer geschrieben werden, den WCF zur Verfügung stellt, und zwar spontan in Base64. Daher werden wir den ersten Weg gehen und die Speicherzuordnung aufheben:

    protected override void OnWriteBodyContents(XmlDictionaryWriter writer)
    {
    	var serializer = Newtonsoft.Json.JsonSerializer.Create(...);
    	writer.WriteStartElement("Binary");
    	Stream stream = new Base64Writer(writer);
    	Var sw = new StreamWriter(stream, new UTF8Encoding(false));
    	using (var jsonWriter = new Newtonsoft.Json.JsonTextWriter(sw))
    	{
    		serializer.Serialize(jsonWriter, _result);
    		jsonWriter.Flush();
    	}
    	writer.WriteEndElement();
    }

    Hier ist Base64Writer ein einfacher Wrapper über XmlWriter, der die Stream-Schnittstelle implementiert, die als Base64 in XmlWriter schreibt. Gleichzeitig genügt es von der gesamten Schnittstelle aus, nur eine Write-Methode zu implementieren, die in StreamWriter aufgerufen wird:

    public class Base64Writer : Stream
    {
    	private readonly XmlWriter _writer;
    	public Base64Writer(XmlWriter writer)
    	{
    		_writer = writer;
    	}
    	public override void Write(byte[] buffer, int offset, int count)
    	{
    		_writer.WriteBase64(buffer, offset, count);
    	}
    	<...>
    }

    Induzierte gc


    Lassen Sie uns versuchen, mit mysteriösen induzierten Müllsammlungen umzugehen. Wir haben unseren Code 10 Mal auf GC.Collect-Aufrufe überprüft, aber das hat nicht funktioniert. Ich habe es geschafft, diese Ereignisse in PerfView abzufangen, aber der Aufrufstapel ist nicht sehr aussagekräftig (DotNETRuntime / GC / Ausgelöstes Ereignis):



    Es gibt einen kleinen Hinweis, der RecycleLimitMonitor.RaiseRecycleLimitEvent vor der induzierten Garbage Collection aufruft. Verfolgen wir den Aufrufstapel mit der RaiseRecycleLimitEvent-Methode:

    RecycleLimitMonitor.RaiseRecycleLimitEvent(...)
    RecycleLimitMonitor.RecycleLimitMonitorSingleton.AlertProxyMonitors(...)
    RecycleLimitMonitor.RecycleLimitMonitorSingleton.CollectInfrequently(...)
    RecycleLimitMonitor.RecycleLimitMonitorSingleton.PBytesMonitorThread(...)

    Die Namen der Methoden entsprechen ihren Funktionen:
    • Im Konstruktor von RecycleLimitMonitor.RecycleLimitMonitorSingleton wird ein Zeitgeber erstellt, der in einem bestimmten Intervall PBytesMonitorThread aufruft.
    • PBytesMonitorThread sammelt Statistiken zur Speichernutzung und ruft unter bestimmten Umständen CollectInfrequently auf.
    • CollectInfrequently ruft AlertProxyMonitors auf, ruft als Ergebnis ein Bool ab und ruft GC.Collect () auf, wenn es wahr ist. Er überwacht auch die Zeit, die seit dem letzten Aufruf des Garbage Collectors vergangen ist, und ruft sie nicht zu oft auf.
    • AlertProxyMonitors durchsucht die Liste der ausgeführten IIS-Webanwendungen, löst für jede das entsprechende RecycleLimitMonitor-Objekt aus und ruft RaiseRecycleLimitEvent auf.
    • RaiseRecycleLimitEvent nimmt die IObserver-Liste auf. Die Handler erhalten als Parameter RecycleLimitInfo, in dem sie das RequestGC-Flag setzen können, das selten zu CollectIn zurückkehrt und eine induzierte Garbage Collection verursacht.


    Weitere Untersuchungen ergeben IObserver-Handlerwerden in der RecycleLimitMonitor.Subscribe () -Methode hinzugefügt, die in der AspNetMemoryMonitor.Subscribe () -Methode aufgerufen wird. Auch in der AspNetMemoryMonitor-Klasse hängt der IObserver-HandlerStandardmäßig (die RecycleLimitObserver-Klasse), die ASP.NET-Caches bereinigt und manchmal nach Garbage Collection fragt.

    Das Rätsel der induzierten GC ist fast gelöst. Es bleibt die Frage zu klären, warum diese Speicherbereinigung aufgerufen wird. RecycleLimitMonitor überwacht die Verwendung des IIS-Speichers (genauer gesagt die Anzahl der privaten Bytes) und beginnt mit einem verwirrenden Algorithmus, um das RaiseRecycleLimitEvent-Ereignis auszulösen, wenn seine Verwendung eine bestimmte Grenze erreicht. Der Wert von AspNetMemoryMonitor.ProcessPrivateBytesLimit wird als Speicherbegrenzung verwendet und enthält wiederum die folgende Logik:
    • Wenn der Anwendungspool in IIS auf "Private Memory Limit (KB)" eingestellt ist, wird der Wert in Kilobyte von dort übernommen
    • Andernfalls werden bei 64-Bit-Systemen 60% des physischen Speichers belegt (bei 32-Bit-Systemen ist die Logik komplizierter).

    Das Ergebnis der Untersuchung lautet wie folgt: ASP.NET nähert sich dem Speicherlimit und ruft regelmäßig die Garbage Collection auf. Das 'Private Memory Limit (KB)' wurde nicht festgelegt, sodass ASP.NET auf 60% des physischen Speichers beschränkt war. Das Problem wurde durch die Tatsache verschleiert, dass auf dem Task-Manager-Server viel freier Speicher angezeigt wurde und es den Anschein hatte, dass dies ausreichte. Wir haben den Wert für "Private Memory Limit (KB)" in den Einstellungen des Anwendungspools in IIS auf 80% des physischen Speichers erhöht. Dies ermutigt ASP.NET, mehr verfügbaren Speicher zu verwenden. Wir haben auch die Überwachung des Leistungsindikators '.NET CLR Memory / # Induced GC' hinzugefügt, um nicht zu versäumen, wenn ASP.NET das nächste Mal feststellt, dass es sich dem Speicherauslastungslimit nähert.

    Wiederholte Messungen


    Mal sehen, was mit der Garbage Collection nach all diesen Änderungen passiert ist. Beginnen wir mit perfview / GCCollectOnly (Ablaufverfolgungszeit - 1 Stunde), GCStats-Bericht:



    Es ist ersichtlich, dass die Baugruppen der 2. Generation jetzt 2 Größenordnungen kleiner sind als die Baugruppen der 0. und 1. Generation. Auch die Zeit dieser Baugruppen nahm ab. Induzierte Anordnungen werden nicht mehr beobachtet. Schauen wir uns die Liste der Baugruppen der 2. Generation an:



    Aus der Spalte Gen geht hervor, dass alle Baugruppen der 2. Generation Hintergrund geworden sind ('2B' bedeutet 2. Generation, Hintergrund). Das heißt, der größte Teil der Arbeit wird parallel zur Ausführung der Anwendung ausgeführt, und alle Threads werden für kurze Zeit blockiert (Spalte 'MSec anhalten'). Schauen wir uns die Pausen beim Erstellen großer Objekte an:



    Es ist zu erkennen, dass die Anzahl solcher Pausen beim Erstellen großer Objekte erheblich gesunken ist.

    Zusammenfassung


    Durch die im Artikel beschriebenen Änderungen konnten Anzahl und Dauer der Baugruppen der 2. Generation deutlich reduziert werden. Ich habe es geschafft, die Ursache der induzierten Versammlungen zu finden und sie loszuwerden. Die Anzahl der Baugruppen der 0. und 1. Generation nahm zu, ihre durchschnittliche Dauer verringerte sich jedoch (von ~ 200 ms auf ~ 60 ms). Die maximale Dauer von Baugruppen der 0. und 1. Generation nahm ab, jedoch nicht so deutlich. Baugruppen der 2. Generation wurden schneller, lange Pausen bis zu 1000 ms sind komplett weg.

    Die Schlüsselmetrik „Prozentsatz langsamer Abfragen“ ist nach allen Änderungen um 40% gesunken.

    Dank unserer Arbeit haben wir erkannt, welche Leistungsindikatoren erforderlich sind, um die Situation bei der Speicher- und Speicherbereinigung zu beurteilen, und sie zu Zabbix hinzugefügt, um sie kontinuierlich zu überwachen. Hier ist eine Liste der wichtigsten, denen wir Aufmerksamkeit schenken, um den Grund herauszufinden (z. B. ein erhöhter Fluss von Anfragen, eine große Menge übertragener Daten, ein Fehler in der Anwendung):
    Leistungsindikator Beschreibung Wann sollte man aufpassen?
    \ Process (*) \ Private Bytes Die für die Anwendung zugewiesene Speichermenge Die Werte überschreiten den Schwellenwert bei weitem. Als Schwellenwert können Sie den Median für 2 Wochen von den maximalen Tageswerten nehmen.
    \ .NET CLR Memory (*) \ # Gen 2 Collections Die Speicherkapazität der älteren Generation
    \ .NET CLR-Speicher (*) \ Größe des Heapspeichers für große Objekte Die Speicherkapazität für große Objekte
    \ .NET CLR-Speicher (*) \% Zeit in GC Der Prozentsatz der Zeit, die zum Sammeln von Müll aufgewendet wurde Der Wert beträgt mehr als 5%.
    \ .NET CLR Memory (*) \ # Induced GC Anzahl induzierter Assemblies Wert ist größer als 0.

    Jetzt auch beliebt: