Aufspüren und Analysieren hoher CPU-Auslastung in .NET-Anwendungen
Veröffentlicht: 2022-03-11Softwareentwicklung kann ein sehr komplizierter Prozess sein. Wir als Entwickler müssen viele verschiedene Variablen berücksichtigen. Einige sind nicht unter unserer Kontrolle, einige sind uns im Moment der eigentlichen Codeausführung unbekannt und einige werden direkt von uns kontrolliert. Und .NET-Entwickler sind da keine Ausnahme.
Angesichts dieser Realität laufen die Dinge normalerweise wie geplant, wenn wir in kontrollierten Umgebungen arbeiten. Ein Beispiel ist unsere Entwicklungsmaschine oder eine Integrationsumgebung, auf die wir vollen Zugriff haben. In diesen Situationen stehen uns Tools zur Verfügung, um verschiedene Variablen zu analysieren, die unseren Code und unsere Software beeinflussen. In diesen Fällen müssen wir uns auch nicht mit einer hohen Belastung des Servers oder gleichzeitigen Benutzern auseinandersetzen, die versuchen, dasselbe zur gleichen Zeit zu tun.
In beschriebenen und sicheren Situationen wird unser Code gut funktionieren, aber in der Produktion unter hoher Last oder einigen anderen externen Faktoren können unerwartete Probleme auftreten. Die Softwareleistung in der Produktion ist schwer zu analysieren. Meistens müssen wir uns mit potenziellen Problemen in einem theoretischen Szenario auseinandersetzen: Wir wissen, dass ein Problem auftreten kann, aber wir können es nicht testen. Aus diesem Grund müssen wir unsere Entwicklung auf die bewährten Verfahren und die Dokumentation für die von uns verwendete Sprache stützen und häufige Fehler vermeiden.
Wie bereits erwähnt, könnten Dinge schief gehen, wenn Software live geht, und Code könnte auf eine Weise ausgeführt werden, die wir nicht geplant hatten. Wir könnten in eine Situation geraten, in der wir uns mit Problemen befassen müssen, ohne die Möglichkeit zu haben, Fehler zu beheben oder sicher zu wissen, was vor sich geht. Was können wir in diesem Fall tun?
In diesem Artikel analysieren wir ein reales Fallbeispiel einer hohen CPU-Auslastung einer .NET-Webanwendung auf dem Windows-basierten Server, die beteiligten Prozesse zur Identifizierung des Problems und, was noch wichtiger ist, warum dieses Problem überhaupt aufgetreten ist und wie wir es getan haben löse es.
CPU-Auslastung und Speicherverbrauch sind viel diskutierte Themen. Normalerweise ist es sehr schwierig, mit Sicherheit zu wissen, welche Menge an Ressourcen (CPU, RAM, E/A) ein bestimmter Prozess für welchen Zeitraum verwenden sollte. Obwohl eines sicher ist - wenn ein Prozess über einen längeren Zeitraum mehr als 90% der CPU verwendet, haben wir Probleme, nur weil der Server unter diesen Umständen keine andere Anfrage verarbeiten kann.
Bedeutet dies, dass es ein Problem mit dem Prozess selbst gibt? Nicht unbedingt. Möglicherweise benötigt der Prozess mehr Rechenleistung oder verarbeitet viele Daten. Zunächst einmal können wir nur versuchen herauszufinden, warum dies geschieht.
Alle Betriebssysteme verfügen über verschiedene Tools zur Überwachung der Vorgänge auf einem Server. Windows-Server verfügen speziell über den Task-Manager, Performance Monitor, oder in unserem Fall haben wir New Relic Server verwendet, ein großartiges Tool zum Überwachen von Servern.
Erste Symptome und Problemanalyse
Nachdem wir unsere Anwendung bereitgestellt hatten, stellten wir während eines Zeitraffers der ersten zwei Wochen fest, dass der Server CPU-Auslastungsspitzen hatte, wodurch der Server nicht mehr reagierte. Wir mussten es neu starten, um es wieder verfügbar zu machen, und dieses Ereignis trat in diesem Zeitraum dreimal auf. Wie ich bereits erwähnt habe, haben wir New Relic Server als Servermonitor verwendet, und es zeigte sich, dass der w3wp.exe
Prozess zum Zeitpunkt des Serverabsturzes 94 % der CPU beanspruchte.
Ein Arbeitsprozess für Internetinformationsdienste (IIS) ist ein Windows-Prozess ( w3wp.exe
), der Webanwendungen ausführt und für die Bearbeitung von Anforderungen verantwortlich ist, die an einen Webserver für einen bestimmten Anwendungspool gesendet werden. Der IIS-Server kann mehrere Anwendungspools (und mehrere verschiedene w3wp.exe
Prozesse) haben, die das Problem verursachen könnten. Basierend auf dem Benutzer, den der Prozess hatte (dies wurde in New Relic-Berichten gezeigt), identifizierten wir, dass das Problem unsere .NET C#-Webformular-Legacy-Anwendung war.
Das .NET Framework ist eng in Windows-Debugging-Tools integriert, daher haben wir als erstes versucht, in der Ereignisanzeige und den Anwendungsprotokolldateien nach nützlichen Informationen zu den Vorgängen zu suchen. Ob wir einige Ausnahmen in der Ereignisanzeige protokolliert hatten, sie lieferten nicht genügend Daten zur Analyse. Aus diesem Grund haben wir uns entschlossen, einen Schritt weiter zu gehen und mehr Daten zu sammeln, damit wir vorbereitet sind, wenn das Ereignis erneut auftritt.
Datensammlung
Der einfachste Weg, Prozessabbilder im Benutzermodus zu sammeln, ist mit Debug Diagnostic Tools v2.0 oder einfach DebugDiag. DebugDiag verfügt über eine Reihe von Tools zum Sammeln von Daten (DebugDiag Collection) und Analysieren von Daten (DebugDiag Analysis).
Beginnen wir also damit, Regeln für das Sammeln von Daten mit Debug-Diagnosetools zu definieren:
Öffnen Sie die DebugDiag-Sammlung und wählen Sie
Performance
aus.- Wählen Sie
Performance Counters
und klicken Sie aufNext
. - Klicken
Add Perf Triggers
. - Erweitern Sie das Objekt
Processor
( nichtProcess
) und wählen Sie% Processor Time
. Beachten Sie, dass Sie bei Windows Server 2008 R2 und mehr als 64 Prozessoren dasProcessor Information
-Objekt anstelle desProcessor
-Objekts auswählen sollten. - Wählen Sie in der Liste der Instanzen
_Total
. - Klicken Sie auf
Add
und dann aufOK
. Wählen Sie den neu hinzugefügten Trigger aus und klicken Sie auf
Edit Thresholds
.- Wählen Sie
Above
in der Dropdown-Liste aus. - Ändern Sie den Schwellenwert auf
80
. Geben Sie
20
für die Anzahl der Sekunden ein. Sie können diesen Wert bei Bedarf anpassen, achten Sie jedoch darauf, keine kleine Anzahl von Sekunden anzugeben, um falsche Trigger zu vermeiden.- Klicken Sie auf
OK
. - Klicken Sie auf
Next
. - Klicken
Add Dump Target
. - Wählen Sie
Web Application Pool
aus der Dropdown-Liste aus. - Wählen Sie Ihren Anwendungspool aus der Liste der Anwendungspools aus.
- Klicken Sie auf
OK
. - Klicken Sie auf
Next
. - Klicken Sie erneut auf
Next
. - Geben Sie bei Bedarf einen Namen für Ihre Regel ein und notieren Sie sich den Speicherort, an dem die Dumps gespeichert werden. Sie können diesen Speicherort bei Bedarf ändern.
- Klicken Sie auf
Next
. - Wählen Sie
Activate the Rule Now
und klicken Sie aufFinish
.
Die beschriebene Regel erstellt eine Reihe von Minidump-Dateien, die relativ klein sein werden. Der endgültige Dump wird ein Dump mit vollem Speicher sein, und diese Dumps werden viel größer sein. Jetzt müssen wir nur noch warten, bis das hohe CPU-Ereignis erneut auftritt.
Sobald wir die Dump-Dateien im ausgewählten Ordner haben, verwenden wir das DebugDiag-Analysetool, um die gesammelten Daten zu analysieren:

Wählen Sie Leistungsanalysen aus.
Fügen Sie die Dump-Dateien hinzu.
Analyse starten.
DebugDiag benötigt einige (oder mehrere) Minuten, um die Dumps zu analysieren und eine Analyse bereitzustellen. Wenn die Analyse abgeschlossen ist, sehen Sie eine Webseite mit einer Zusammenfassung und vielen Informationen zu Threads, ähnlich der folgenden:
Wie Sie in der Zusammenfassung sehen können, gibt es eine Warnung, die besagt: „Hohe CPU-Auslastung zwischen Dump-Dateien wurde in einem oder mehreren Threads erkannt.“ Wenn wir auf die Empfehlung klicken, beginnen wir zu verstehen, wo das Problem mit unserer Anwendung liegt. Unser Beispielbericht sieht so aus:
Wie wir im Bericht sehen können, gibt es ein Muster in Bezug auf die CPU-Auslastung. Alle Threads mit hoher CPU-Auslastung gehören zur selben Klasse. Bevor wir zum Code springen, werfen wir einen Blick auf den ersten.
Dies ist das Detail für den ersten Thread mit unserem Problem. Der Teil, der für uns interessant ist, ist der folgende:
Hier haben wir einen Aufruf unseres Codes GameHub.OnDisconnected()
, der die problematische Operation ausgelöst hat, aber vor diesem Aufruf haben wir zwei Dictionary-Aufrufe, die möglicherweise eine Vorstellung davon geben, was vor sich geht. Werfen wir einen Blick in den .NET-Code, um zu sehen, was diese Methode tut:
public override Task OnDisconnected() { try { var userId = GetUserId(); string connId; if (onlineSessions.TryGetValue(userId, out connId)) onlineSessions.Remove(userId); } catch (Exception) { // ignored } return base.OnDisconnected(); }
Hier haben wir offensichtlich ein Problem. Die Aufrufliste der Berichte besagte, dass das Problem mit einem Wörterbuch auftrat, und in diesem Code greifen wir auf ein Wörterbuch zu, und insbesondere die Zeile, die das Problem verursacht, ist diese:
if (onlineSessions.TryGetValue(userId, out connId))
Dies ist die Wörterbuchdeklaration:
static Dictionary<int, string> onlineSessions = new Dictionary<int, string>();
Was ist das Problem mit diesem .NET-Code?
Jeder, der Erfahrung mit objektorientierter Programmierung hat, weiß, dass statische Variablen von allen Instanzen dieser Klasse gemeinsam genutzt werden. Werfen wir einen genaueren Blick darauf, was statisch in der .NET-Welt bedeutet.
Gemäß der .NET C#-Spezifikation:
Verwenden Sie den statischen Modifikator, um einen statischen Member zu deklarieren, der zum Typ selbst und nicht zu einem bestimmten Objekt gehört.
Dies ist, was die Spezifikation der .NET C#-Sprache in Bezug auf statische Klassen und Mitglieder sagt:
Wie bei allen Klassentypen werden die Typinformationen für eine statische Klasse von der Common Language Runtime (CLR) von .NET Framework geladen, wenn das Programm geladen wird, das auf die Klasse verweist. Das Programm kann nicht genau angeben, wann die Klasse geladen wird. Es ist jedoch garantiert, dass sie geladen wird und dass ihre Felder initialisiert und ihr statischer Konstruktor aufgerufen wird, bevor die Klasse zum ersten Mal in Ihrem Programm referenziert wird. Ein statischer Konstruktor wird nur einmal aufgerufen, und eine statische Klasse bleibt für die Lebensdauer der Anwendungsdomäne, in der sich Ihr Programm befindet, im Speicher.
Eine nicht statische Klasse kann statische Methoden, Felder, Eigenschaften oder Ereignisse enthalten. Der statische Member ist für eine Klasse aufrufbar, selbst wenn keine Instanz der Klasse erstellt wurde. Auf das statische Mitglied wird immer über den Klassennamen zugegriffen, nicht über den Instanznamen. Es existiert nur eine Kopie eines statischen Members, unabhängig davon, wie viele Instanzen der Klasse erstellt werden. Statische Methoden und Eigenschaften können nicht auf nicht statische Felder und Ereignisse in ihrem enthaltenden Typ zugreifen, und sie können nicht auf eine Instanzvariable eines Objekts zugreifen, es sei denn, sie wird explizit in einem Methodenparameter übergeben.
Das bedeutet, dass die statischen Member zum Typ selbst gehören, nicht zum Objekt. Sie werden auch von der CLR in die Anwendungsdomäne geladen, daher gehören die statischen Member zu dem Prozess, der die Anwendung hostet, und nicht zu bestimmten Threads.
Angesichts der Tatsache, dass eine Webumgebung eine Multithread-Umgebung ist, da jede Anfrage ein neuer Thread ist, der vom w3wp.exe
Prozess erzeugt wird; und da die statischen Member Teil des Prozesses sind, haben wir möglicherweise ein Szenario, in dem mehrere verschiedene Threads versuchen, auf die Daten statischer (von mehreren Threads gemeinsam genutzter) Variablen zuzugreifen, was schließlich zu Multithreading-Problemen führen kann.
Die Dictionary-Dokumentation unter Thread-Sicherheit besagt Folgendes:
Ein
Dictionary<TKey, TValue>
kann mehrere Reader gleichzeitig unterstützen, solange die Auflistung nicht geändert wird. Trotzdem ist das Aufzählen durch eine Sammlung an sich kein Thread-sicheres Verfahren. Für den seltenen Fall, dass eine Enumeration mit Schreibzugriffen konkurriert, muss die Collection während der gesamten Enumeration gesperrt werden. Damit mehrere Threads zum Lesen und Schreiben auf die Sammlung zugreifen können, müssen Sie Ihre eigene Synchronisierung implementieren.
Diese Aussage erklärt, warum wir dieses Problem möglicherweise haben. Basierend auf den Dump-Informationen lag das Problem bei der FindEntry-Methode des Wörterbuchs:
Wenn wir uns die FindEntry-Implementierung des Wörterbuchs ansehen, sehen wir, dass die Methode die interne Struktur (Buckets) durchläuft, um den Wert zu finden.
Der folgende .NET-Code listet also die Auflistung auf, was kein threadsicherer Vorgang ist.
public override Task OnDisconnected() { try { var userId = GetUserId(); string connId; if (onlineSessions.TryGetValue(userId, out connId)) onlineSessions.Remove(userId); } catch (Exception) { // ignored } return base.OnDisconnected(); }
Fazit
Wie wir in den Dumps gesehen haben, versuchen mehrere Threads gleichzeitig, eine gemeinsam genutzte Ressource (statisches Wörterbuch) zu iterieren und zu ändern, was schließlich dazu führte, dass die Iteration in eine Endlosschleife eintritt, wodurch der Thread mehr als 90 % der CPU verbraucht .
Es gibt mehrere mögliche Lösungen für dieses Problem. Das erste, das wir implementiert haben, war das Sperren und Synchronisieren des Zugriffs auf das Wörterbuch auf Kosten von Leistungseinbußen. Der Server stürzte zu dieser Zeit jeden Tag ab, also mussten wir dies so schnell wie möglich beheben. Auch wenn dies nicht die optimale Lösung war, löste es das Problem.
Der nächste Schritt zur Lösung dieses Problems wäre, den Code zu analysieren und die optimale Lösung dafür zu finden. Das Umgestalten des Codes ist eine Option: Die neue ConcurrentDictionary-Klasse könnte dieses Problem lösen, da sie nur auf Bucket-Ebene sperrt, was die Gesamtleistung verbessert. Dies ist jedoch ein großer Schritt, und weitere Analysen wären erforderlich.