GC-Wartezeit von einem Speicherleck in .NET unterscheiden — Ein praktisches Verfahren, um wachsenden Speicher zu beobachten, zu vergleichen und zu belegen

· · .NET, C#, GC, Speicherleck, Diagnose, dotnet-counters, dotnet-dump, Betrieb, Nutzung von Altbeständen

1. Das Wichtigste vorab

Beim Betrieb einer .NET-Anwendung gibt es Situationen, in denen der Speicherverbrauch langsam, aber stetig steigt.

Task-Manager oder top zeigen, dass der Speicher des Prozesses wächst. Auch der Speicherverbrauch des Containers wächst. Im Monitoring zeigt der Graph für Working Set oder RSS eine steigende Tendenz.

Bei diesem Anblick liegt der Gedanke „ist das ein Speicherleck?“ nahe. In .NET sind jedoch „der Speicherverbrauch eines Prozesses wächst“ und „es liegt ein Speicherleck vor“ nicht dasselbe.

.NET verfügt über eine Garbage Collection. Sobald ein Objekt nicht mehr benötigt wird, wird der Speicher nicht sofort an das Betriebssystem zurückgegeben. Die GC arbeitet unter Berücksichtigung von Zuweisungsverhalten, Heap-Schwellenwerten, Speicherdruck, Generationen und Workload-Zustand.

Dadurch entstehen unter anderem folgende Situationen:

  • Ein nicht mehr benötigtes Objekt wurde noch nicht von der GC eingesammelt
  • Die GC ist bereits gelaufen, aber der Working Set des Prozesses sinkt nicht sofort
  • Beim ersten Zugriff, durch JIT, Caches oder Connection-Pools wächst der Speicher einmalig und stabilisiert sich danach
  • Der Managed Heap ist stabil, aber der native Speicher, Threads, Sockets oder eine Bildverarbeitungsbibliothek wachsen
  • Ein Objekt, das tatsächlich nicht mehr benötigt wird, wird weiterhin von irgendwoher referenziert

Dieser Artikel behandelt, wie sich der letzte Fall — „es liegt wirklich ein Leck vor“ — erkennen lässt. Zu betrachten sind nicht der reine Speicherverbrauch, sondern diese drei Punkte:

  1. Wächst der auch nach der GC überlebende Speicher?
  2. Welcher Typ wächst?
  3. Wer referenziert dieses Objekt?

Eine .NET-Speicherleck-Untersuchung ist die Arbeit, nicht bei „der Speicher wächst“ stehen zu bleiben, sondern bis zu „Objekte dieses Typs wachsen und werden weiterhin von dieser Wurzel aus referenziert“ zu kommen.

Der in diesem Artikel vorkommende Code ist als vollständiges, bau- und lauffähiges Beispielpaket (eine Bibliothek typischer Leck-Muster, eine Demo zur Beobachtung des Unterschieds zwischen GC-Wartezeit und tatsächlichem Überleben, sowie Unit-Tests, die Festhalten und Einsammeln mit WeakReference prüfen) auf GitHub veröffentlicht.

dotnet-gc-or-memory-leak - komurasoft-blog-samples (GitHub)

2. Zunächst die Bedeutung von „Speicherleck“ abstimmen

Ein Speicherleck in .NET ist nicht nur die aus C oder C++ bekannte Form „reservierten Speicher vergessen freizugeben“.

In verwaltetem Code sammelt die GC Objekte ein. Ob die GC ein Objekt einsammeln kann, entscheidet sich daran, ob noch eine erreichbare Referenz darauf besteht.

Mit anderen Worten sieht ein typisches .NET-Speicherleck so aus:

Ein Objekt wird fachlich nicht mehr benötigt, wird aber weiterhin referenziert — von einem static-Feld, einem Cache, einem Event, einem Timer, einer Collection, einer DI-Lebensdauer, einem asynchronen Kontext usw. —, sodass es aus Sicht der GC noch in Benutzung aussieht.

Die GC ist klug, weiß aber nicht, ob etwas fachlich noch benötigt wird. Wird es referenziert, gilt es als lebendig.

Deshalb ist es in .NET klarer, eher von „unbeabsichtigtem Festhalten“ als von einem „Leck“ zu sprechen.

Die folgenden Zustände lassen sich dagegen nicht sofort als Speicherleck bezeichnen:

Zustand Warum es nicht zwingend ein Leck ist
Working Set / RSS wächst Das ist der vom Betriebssystem dem Prozess zugewiesene Speicher; er entspricht nicht der Menge lebender Objekte im Managed Heap
Total Allocated wächst Das ist die kumulative Zuweisungsmenge seit dem Start, sie wächst grundsätzlich, solange die Anwendung läuft
GC Heap wächst kurzzeitig Es könnten einfach noch nicht eingesammelte Objekte bis zur nächsten GC übrig sein
Wächst direkt nach dem Start Kommt häufig durch JIT, Typladen, Anfangs-Caches, Connection-Pools, Template-Expansion vor
LOH ist groß Könnte an Wiederverwendung großer Arrays und Puffer, Fragmentierung oder Pooling-Strategie liegen
Speicher sinkt nicht Auch wenn die GC einsammelt, gibt der Prozess den Speicher nicht zwingend sofort an das Betriebssystem zurück

Umgekehrt gilt: Je mehr der folgenden Zustände gleichzeitig zutreffen, desto stärker der Verdacht auf ein Speicherleck.

Beobachtung Bedeutung
Bei jeder Wiederholung derselben Operation wächst der Heap nach der GC Es überleben immer mehr Objekte
Die Größe von Gen 2 oder LOH wächst kontinuierlich Langlebige Objekte oder große Objekte bleiben liegen
Über mehrere Dumps hinweg wächst Count / Size desselben Typs Der wachsende Typ lässt sich identifizieren
gcroot zeigt Referenzen von static-Feldern, Events, Caches oder langlebigen Diensten Es lässt sich erklären, warum die GC nicht einsammeln kann
Auch nach ausreichender Wartezeit oder einer Prüf-GC kehrt der Speicher nach Stoppen der Last nicht zurück Es handelt sich mit hoher Wahrscheinlichkeit nicht nur um eine temporäre Zuweisung

3. Trennen, „welchen Speicher“ man gerade betrachtet

Die erste Verwirrung bei einer Speicheruntersuchung entsteht dadurch, dass verschiedene Speicherkennzahlen vermischt werden. Auch wenn überall „Speicher“ steht, ist jeweils etwas anderes gemeint.

Kennzahl Was sie zeigt Wie man sie liest
Working Set / RSS Die im physischen Speicher liegenden Seiten des Prozesses Sicht des Betriebssystems auf den Speicher. Nicht der GC-Heap selbst
Private Bytes / Commit Vom Prozess privat belegter, committeter Speicher Umfasst auch nativen Speicher, Stacks, JIT-Code, GC-Segmente
GC Heap Size Die Menge an Objekten im Managed Heap Der Einstiegspunkt, um den von der GC verwalteten Speicher in .NET zu betrachten
Total Allocated Kumulative Zuweisungsmenge seit dem Start Wächst grundsätzlich immer weiter; für die Leck-Beurteilung allein ungeeignet
Gen 0 / Gen 1 / Gen 2 Heap je Generation Was in Gen 2 verbleibt, ist langlebig
LOH Heap für große Objekte ab 85.000 Byte Wächst leicht durch große Arrays, Strings, Puffer
POH Heap für gepinnte Objekte Ein Hinweis auf die Auswirkungen von nativer Interop und Pinning
Finalization Queue Objekte, die auf Finalisierung warten Ein Hinweis auf vergessenes Dispose oder einen verstopften Finalizer

Wie sich die einzelnen Kennzahlen zueinander verhalten, zeigt dieses Diagramm.

wird angerechnetwird angerechnetnur der im physischen Speicher liegende AnteilVom Prozess verwendeter SpeicherManaged-SeiteÜber GC Heap Size sichtbarer BereichNative SeiteIn GC Heap Size nicht sichtbarer BereichGen 0 / Gen 1Kurzlebige ZuweisungenGen 2Überlebende langlebige ObjekteLOHGroße Objekte ab 85.000 BytePOHGepinnte ObjekteThread-StacksJIT-kompilierter Code, geladene AssemblysPuffer von P/Invoke, COM, externen BibliothekenPrivate Bytes / CommitVom Prozess privat belegter, committeter SpeicherWorking Set / RSS

Bei diesem Diagramm sind zwei Punkte wichtig:

  1. Mit dumpheap sieht man nur die Managed-Seite. Wächst die native Seite, findet sich der Täter auch bei noch so genauem Blick in den Heap nicht.
  2. Working Set und Commit stehen nicht in einer Schachtelungsbeziehung. Auch committeter Speicher erscheint nicht im Working Set, wenn er nicht im physischen Speicher liegt, und umgekehrt können Seiten, die nicht privat sind — etwa von gemeinsam genutzten Bibliotheken —, in den Working Set eingerechnet werden. Deshalb lässt sich nicht einfach sagen: „Der Working Set sinkt nicht, also sammelt die GC nicht ein.“

Sie müssen nicht von Anfang an alles im Detail betrachten. Zerlegen Sie die Frage zunächst so:

Der Speicherverbrauch des Prozesses wächst
  ↓
Wächst auch der Managed Heap?
  ↓
Wächst die Menge, die die GC überlebt?
  ↓
Welcher Typ wächst?
  ↓
Wer referenziert ihn?

Hält man diese Reihenfolge ein, verwechselt man „scheinbares Speicherwachstum“ seltener mit „einem echten Leck“.

4. Der Entscheidungsablauf

In der Praxis lässt sich die Eingrenzung mit folgendem Ablauf gut vorantreiben.

1. Reproduktionsbedingungen festlegen
   - Bei welcher API, welchem Bildschirm, welchem Job oder Batch wächst es
   - Nach wie vielen Ausführungen wächst es
   - Was passiert, wenn die Last gestoppt wird

2. Mit dotnet-counters die Tendenz betrachten
   - Working Set
   - GC Heap
   - Gen 2 / LOH
   - Total Allocated
   - Anzahl der GC-Läufe

3. Über die Zeit vergleichen
   - Direkt nach dem Start
   - Nach dem Aufwärmen
   - Unter Last
   - Nach Stoppen der Last
   - Nach N-facher Wiederholung derselben Operation

4. Mindestens zwei Dumps nehmen
   - before
   - after
   - wenn möglich, auch nach Stoppen der Last

5. Den wachsenden Typ suchen
   - dumpheap -stat
   - gcdump report
   - Visual Studio / PerfView

6. Die Referenzquelle prüfen
   - gcroot
   - gchandles
   - finalizequeue

7. Eine Einschätzung treffen
   - GC-Wartezeit
   - Normales Cache-Wachstum
   - Managed-Speicherleck
   - Problem mit nativem Speicher
   - LOH-Fragmentierung oder vorübergehend große Zuweisungen

Wichtig ist, nicht anhand eines einzelnen Werts zu urteilen. Ein Speicherleck ist eine „kontinuierlich wachsende Tendenz“, deshalb vergleicht man über die Zeit unter gleichen Bedingungen statt anhand eines Einzelwerts.

5. Verwendete Werkzeuge

Dieser Artikel verwendet hauptsächlich die folgenden Werkzeuge.

Werkzeug Einsatzbereich
dotnet-counters Die Tendenz von GC und Working Set eines laufenden Prozesses betrachten
dotnet-gcdump Statistiken lebender Managed-Objekte leichtgewichtig erfassen
dotnet-dump Den Heap im Detail betrachten und mit dumpheap und gcroot bis zur Referenzquelle verfolgen
Visual Studio Memory Usage Für einen GUI-Vergleich unter Windows
PerfView Für eine tiefergehende Betrachtung von GC / Heap / Trace unter Windows
dotnet-trace Um Zuweisungen und GC-Ereignisse zeitlich zu verfolgen

Zuerst installieren Sie die CLI-Werkzeuge.

dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-dump
dotnet tool install --global dotnet-gcdump
dotnet tool install --global dotnet-trace

Sind sie bereits installiert, aktualisieren Sie sie.

dotnet tool update --global dotnet-counters
dotnet tool update --global dotnet-dump
dotnet tool update --global dotnet-gcdump
dotnet tool update --global dotnet-trace

Suchen Sie den zu untersuchenden Prozess.

dotnet-counters ps

In den folgenden Beispielen wird die Ziel-Prozess-ID als <PID> geschrieben.

Unter Linux, macOS und in Container-Umgebungen müssen die Diagnosewerkzeuge unter demselben Benutzer laufen wie der Zielprozess. Je nach Umgebung wirken sich außerdem TMPDIR, der Diagnoseport und der PID-Namespace des Containers aus.

Führen Sie es in der Produktion aus, nehmen Sie nicht sofort einen Dump, sondern prüfen Sie zunächst Last und Auswirkung in einer Testumgebung.

5.1 Wenn das Untersuchungsziel .NET Framework 4.x ist

dotnet-counters, dotnet-dump und dotnet-gcdump sind Werkzeuge, die die Diagnosefunktionen einer .NET-Core-3.0-oder-neuer-Runtime nutzen. Ist das Untersuchungsziel eine .NET-Framework-4.x-Anwendung, lassen sie sich nicht verwenden. Das betrifft Sie, wenn Sie eine bestehende Windows-Forms-, WPF- oder ASP.NET-Anwendung warten.

Der Ersatz ergibt sich aus dieser Zuordnung.

Werkzeug in diesem Artikel Ersatz unter .NET Framework 4.x
Tendenz mit dotnet-counters betrachten Leistungsindikatoren (Performance Monitor) oder Get-Counter mit den Zählern der Kategorie .NET CLR Memory
Typstatistik mit dotnet-gcdump vergleichen GC-Heap-Dump von PerfView, oder Snapshots in „Speicherauslastung“ von Visual Studio vergleichen
Dump mit dotnet-dump collect nehmen ProcDump, „Vollständigen Speicherabbild erstellen“ im Task-Manager, oder Dumps über die Einstellungen der Windows-Fehlerberichterstattung
dumpheap / gcroot mit dotnet-dump analyze In WinDbg zunächst .loadby sos clr ausführen, dann !dumpheap -stat bzw. !gcroot verwenden
Zuweisungen mit dotnet-trace verfolgen GC-Heap-Allocation-Erfassung von PerfView

Die Denkweise ist völlig identisch — dreistufig aus „Tendenz betrachten“, „Typstatistik zweimal vergleichen“, „Referenzquelle verfolgen“. Nur das Werkzeug ändert sich.

Betrachten Sie es mit dem Leistungsindikatoren-Tool, sind das die zuerst interessanten Zähler in der Kategorie .NET CLR Memory.

Zähler Was er zeigt
# Bytes in all Heaps Summe aus Gen 1, Gen 2 und LOH. Kommt dem in diesem Artikel genannten GC Heap Size nahe
Gen 2 heap size Aktuelle Bytegröße von Gen 2. Wächst sie kontinuierlich, ist ein Leck zu vermuten
Large Object Heap size Aktuelle Größe des LOH
# Gen 2 Collections Anzahl der vollständigen GC-Läufe. Steigt sie sprunghaft, ist eine übermäßige Zuweisungsrate zu vermuten
% Time in GC Anteil der Zeit, die im letzten GC-Zyklus für die GC aufgewendet wurde
Finalization Survivors Anzahl der Objekte, die die Finalisierungswarteschlange überlebt haben. Ein Hinweis auf vergessenes Dispose
# Total committed Bytes Von der GC committeter virtueller Speicher

Die Zählernamen können in einer deutschsprachigen Umgebung lokalisiert angezeigt werden. Finden Sie sie nicht, suchen Sie nicht nur nach dem englischen Namen, sondern auch nach lokalisierten Kategorienamen wie „.NET CLR-Speicher“.

Der Einstieg für die Analyse mit WinDbg ist das Laden von SOS.

0:000> .loadby sos clr
0:000> !dumpheap -stat
0:000> !gcroot <OBJECT_ADDRESS>

Unter .NET Framework wird den SOS-Befehlen ein ! vorangestellt. Die ab Kapitel 9 in diesem Artikel vorkommenden Befehle dumpheap -stat und gcroot lassen sich, gelesen als !dumpheap -stat bzw. !gcroot, unverändert als dasselbe Verfahren verwenden.

6. Zunächst mit dotnet-counters die Tendenz betrachten

Als Erstes betrachtet man nicht den detaillierten Dump, sondern die Tendenz.

dotnet-counters monitor \
  --process-id <PID> \
  --refresh-interval 3 \
  --counters System.Runtime

Die Ausgabe unterscheidet sich je nach .NET-Version leicht. Ab .NET 9 erfolgt die Anzeige unter den Meter-Namen von System.Runtime, bei .NET 8 und früher mitunter unter den herkömmlichen EventCounter-Namen.

Zu betrachten sind hauptsächlich diese Punkte.

Zu betrachtender Punkt Was er zeigt
dotnet.process.memory.working_set Der residente Speicher des Prozesses aus Sicht des Betriebssystems
dotnet.gc.last_collection.heap.size Heap-Größe je Generation nach der letzten GC
dotnet.gc.last_collection.memory.committed_size Von der GC committeter Speicherbetrag
dotnet.gc.heap.total_allocated Kumulative Zuweisungsmenge seit dem Start
dotnet.gc.collections Anzahl der GC-Läufe je Generation
dotnet.gc.pause.time Kumulierte GC-Pausenzeit

Sie können die Überwachung auch von vornherein auf ausgewählte Zähler einschränken.

dotnet-counters monitor \
  --process-id <PID> \
  --refresh-interval 3 \
  --counters System.Runtime[dotnet.process.memory.working_set,dotnet.gc.last_collection.heap.size,dotnet.gc.last_collection.memory.committed_size,dotnet.gc.heap.total_allocated,dotnet.gc.collections]

Wollen Sie es später erneut betrachten, speichern Sie es als CSV.

dotnet-counters collect \
  --process-id <PID> \
  --refresh-interval 5 \
  --format csv \
  --output counters.csv \
  --counters System.Runtime

An dieser Stelle wollen Sie folgende Unterschiede erkennen.

6.1 Nur Total Allocated wächst

dotnet.gc.heap.total_allocated ist ein kumulativer Wert. Verarbeitet die Anwendung Anfragen, weist sie Objekte zu, und selbst wenn die zugewiesenen Objekte sofort nicht mehr benötigt und von der GC eingesammelt werden, wächst die kumulative Zuweisungsmenge trotzdem.

Allein das Wachsen von Total Allocated bedeutet daher kein Speicherleck. Zu prüfen ist, ob die Zuweisung danach bestehen bleibt.

Total Allocated: wächst
GC Heap Size:    schwankt etwas, bleibt aber stabil
Gen 2 / LOH:     wächst nicht kontinuierlich weiter

In diesem Fall handelt es sich weniger um ein Leck als um eine Anwendung mit hohem Zuweisungsvolumen.

Die Gegenmaßnahme ist nicht die Behebung eines Lecks, sondern die Reduzierung der Zuweisungen: Pufferwiederverwendung, Überprüfung übermäßiger LINQ-Nutzung, Reduzierung der Stringerzeugung, Überarbeitung der Serialisierung und Ähnliches.

6.2 Working Set wächst, aber GC Heap bleibt stabil

Working Set oder RSS können wachsen, während der GC Heap stabil bleibt. In diesem Fall handelt es sich nicht zwingend um ein Leck bei Managed-Objekten.

Mögliche Faktoren sind unter anderem:

  • JIT-kompilierter Code
  • Geladene Assemblys
  • Thread-Stacks
  • Speicher nativer Bibliotheken
  • Unmanaged-Speicher wie über Marshal.AllocHGlobal
  • Native Puffer bei Bildverarbeitung, Kompression, Kryptografie, DB-Treibern
  • Interne Puffer für Sockets, Dateihandles, SSL, HTTP/2, gRPC
  • Das Betriebssystem gibt physische Seiten des Prozesses einfach noch nicht frei

In diesem Zustand findet sich auch bei noch so langem Blick in dumpheap mitunter kein Hauptverdächtiger.

Die Faustregel lautet:

Working Set / RSS: wächst
GC Heap Size:      stabil
Gen 2 / LOH:       stabil

In diesem Fall vermuten Sie eher nativen Speicher, Handles, Thread-Anzahl, Sockets oder externe Bibliotheken als ein Managed-Heap-Leck in .NET.

Bleiben Sie nicht bei dotnet-counters stehen — betrachten Sie auch Betriebssystem-Tools, Container-Metriken, Handle-Anzahl, Thread-Anzahl, den nativen Heap und Metriken externer Bibliotheken.

6.3 GC Heap wächst, kehrt aber nach Stoppen der Last zurück

Dass der GC Heap unter Last wächst, ist normal.

Viele Anfragen. Viele temporäre Objekte. Verarbeitung großer JSON-Daten. Vorübergehendes Anlegen von Listen oder Arrays.

In solchen Fällen wächst der Heap bis zur nächsten GC. Stoppt die Last, läuft eine GC, und der Heap kann zurückgehen.

Unter Last:            GC Heap wächst
Nach Stoppen der Last: GC Heap sinkt oder kehrt zu einem stabilen Wert zurück
Nach Wiederholungen:   die Basislinie wächst nicht weiter

In diesem Fall lässt sich urteilen: „Es wurde nur noch nicht eingesammelt“ oder „es gibt viele temporäre Zuweisungen“.

Sind die temporären Zuweisungen unter Last jedoch zu zahlreich, steigen GC-Anzahl und Pausenzeit und werden zu einem Leistungsproblem. Auch ohne Leck bleibt das ein Ziel für Leistungsverbesserungen.

6.4 Gen 2 / LOH wachsen nach der GC kontinuierlich weiter

Dieses Muster verdient besondere Aufmerksamkeit.

Dieselbe Operation wiederholen
  ↓
Gen 2 wächst
  ↓
LOH wächst
  ↓
Kehrt auch nach Stoppen der Last nicht zurück
  ↓
Bei der nächsten Messung noch weiter gewachsen

Gen 2 ist die Generation, in der langlebige Objekte landen. Der LOH ist der Heap, in dem sich leicht große Arrays oder Strings ansammeln.

Wächst dieser Bereich kontinuierlich weiter, ist ein Leck, ein unbegrenzter Cache, das Festhalten riesiger Puffer, eine fehlende Abmeldung von Events, eine static-Collection oder das Festhalten durch einen langlebigen Dienst zu vermuten.

An dieser Stelle geht es weiter zum nächsten Schritt.

7. Die Denkweise, um zu prüfen, ob es sich um „GC-Wartezeit“ handelt

Um zu prüfen, ob etwas „nur noch nicht eingesammelt wurde“, betrachtet man den Zustand, nachdem die GC ausreichend Gelegenheit hatte zu laufen.

Setzen Sie aber nicht leichtfertig GC.Collect() in Produktionscode ein.

GC.Collect() erzwingt eine GC. Besonders eine blockierende GC über alle Generationen erzeugt eine Pausenzeit der Anwendung. Im normalen Betrieb ist es Grundregel, die GC selbst entscheiden zu lassen.

Dennoch kommt es bei einer Untersuchung vor, in einer kontrollierten Testumgebung zu prüfen, „ob etwas auch nach einer erzwungenen GC übrig bleibt“.

In einer Test-Konsolenanwendung oder Reproduktionsumgebung lässt sich der Zustand nach einer vollständigen GC mit folgendem Code prüfen.

static void ForceFullGcForDiagnosticsOnly()
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();
}

Wichtig ist, das nicht als Lösung einzusetzen. Es dient ausschließlich der Untersuchung.

Zu prüfen ist dieser Ablauf:

Vor der Operation
  ↓
Die Operation N-mal wiederholen
  ↓
Die Last stoppen
  ↓
Ausreichend warten oder in der Testumgebung eine vollständige GC auslösen
  ↓
Kehrt der Heap nach der GC nahe an den Wert vor der Operation zurück?

Kehrt er zurück, handelt es sich mit hoher Wahrscheinlichkeit um GC-Wartezeit oder temporäre Zuweisungen. Kehrt er nicht zurück und steigt die Basislinie bei jeder Wiederholung derselben Operation weiter, überlebt etwas. Dieses „Etwas“ suchen Sie dann im Dump.

8. Mit dotnet-gcdump leicht vergleichen

Für den ersten Vergleich eignet sich dotnet-gcdump.

dotnet-gcdump lässt sich verwenden, um aus einem laufenden .NET-Prozess einen GC-Dump zu erfassen und Statistiken je Typ im Heap zu betrachten.

dotnet-gcdump collect --process-id <PID> --output before.gcdump

Nach dem Aufbringen von Last nehmen Sie ihn ein weiteres Mal.

dotnet-gcdump collect --process-id <PID> --output after.gcdump

Sie können sich auch über die CLI einen einfachen Bericht ansehen.

dotnet-gcdump report before.gcdump > before-heap.txt
dotnet-gcdump report after.gcdump  > after-heap.txt

Zu betrachten sind Count und Size je Typ.

Sind zum Beispiel in after folgende Typen deutlich gewachsen, werden sie zum Untersuchungsgegenstand.

Size (Bytes)   Count       Type
============   =====       ====
180,000,000    2,000,000   System.String
120,000,000    1,000,000   MyApp.Models.Customer
 90,000,000       25,000   System.Byte[]

Wichtig ist nicht der „große Typ“, sondern der „gewachsene Typ“.

System.String und System.Byte[] stehen bei vielen Anwendungen weit oben. Allein weit oben zu stehen bedeutet nicht, der Täter zu sein.

Die Vergleichspunkte lauten:

before → after Interpretation
Count bleibt praktisch gleich Dieser Typ ist mit hoher Wahrscheinlichkeit nicht der Hauptverdächtige
Count und Size wachsen beide Wird zu einem Kandidaten
Ein MyApp.*-Typ wächst Ein Festhalten in der Fachlogik ist leicht zu vermuten
System.Byte[] wächst Puffer, Serialisierung, Bilder, Kompression, HTTP, DB sind zu vermuten
System.String wächst Cache, Logging, JSON, Dictionary-Keys, doppelte Strings sind zu vermuten
Task, Timer, CancellationTokenSource wachsen Asynchrone Verarbeitung, Timer, fehlende Abmeldung von Cancellation sind zu vermuten

dotnet-gcdump eignet sich gut als Einstieg für Vergleiche, löst beim Erfassen aber eine Gen-2-GC aus. In Umgebungen mit großem Heap oder strengen Latenzanforderungen ist auf Pausenzeit und zusätzlichen Speicherverbrauch zu achten.

Unter Windows lässt sich die .gcdump-Datei zum Vergleich in Visual Studio oder PerfView öffnen. In Nicht-Windows-Umgebungen ist es praxisnah, die Typstatistik mit dem CLI-report zu betrachten und für die tiefere Untersuchung der Referenzquelle mit dotnet-dump fortzufahren.

9. Mit dotnet-dump Heap und Referenzquelle betrachten

Ist der „wachsende Typ“ erkennbar, betrachten Sie als Nächstes, „warum er nicht eingesammelt wird“.

Dafür nehmen Sie mit dotnet-dump einen Dump und analysieren ihn mit SOS-Befehlen.

dotnet-dump collect \
  --process-id <PID> \
  --type Heap \
  --output myapp-1.dmp

Nach einer gewissen Zeit nehmen Sie ihn erneut.

dotnet-dump collect \
  --process-id <PID> \
  --type Heap \
  --output myapp-2.dmp

Das Erfassen eines Dumps ist ein aufwendiger Vorgang. Besonders Full- oder Heap-Dumps sind groß und belasten Prozess und Container. Nehmen Sie sie in der Produktion auf, achten Sie auf Tageszeit, Festplattenkapazität, Container-Speicherlimits sowie das Vorhandensein personenbezogener oder vertraulicher Daten.

Analysieren Sie den erfassten Dump.

dotnet-dump analyze myapp-2.dmp

Betrachten Sie zunächst die Gesamtstatistik des Heaps.

> dumpheap -stat

Die Ausgabe zeigt Anzahl und Größe je Typ.

MT               Count       TotalSize Class Name
00007f...        120000      3840000   MyApp.Models.Order
00007f...        250000      8000000   System.String
00007f...         10000     40000000   System.Byte[]

Grenzen Sie auf einen bestimmten Typ ein.

> dumpheap -stat -type MyApp.Models.Order

Oder grenzen Sie über eine bestimmte MethodTable ein.

> dumpheap -mt <MT>

Ist die Adresse einer Instanz bekannt, untersuchen Sie die Referenzquelle.

> gcroot <OBJECT_ADDRESS>

Das ist der wichtigste Punkt. Mit gcroot prüfen Sie, warum dieses Objekt noch lebt.

Angenommen, folgender Referenzpfad wird sichtbar:

static MyApp.CustomerCache._items
  -> System.Collections.Concurrent.ConcurrentDictionary<string, Customer>
  -> MyApp.Models.Customer
  -> System.String

In diesem Fall ist der Grund, warum die GC nicht einsammelt, eindeutig. Customer wird von einem static-Cache referenziert und gilt aus Sicht der GC daher noch als in Benutzung.

Erst jetzt lassen sich folgende Fragen beurteilen:

  • Wird dieser Cache wirklich benötigt?
  • Gibt es eine Obergrenze?
  • Gibt es eine Ablaufzeit?
  • Ist das Design so, dass die Anzahl der Keys immer weiter wächst?
  • Wächst er unbegrenzt, weil Mandant, Benutzer, Datum oder Request-ID als Key verwendet werden?

Bei der Speicherleck-Untersuchung ist wichtig, nicht bei dumpheap -stat stehen zu bleiben. dumpheap -stat zeigt „was viel ist“, gcroot zeigt „warum es übrig bleibt“. Zur Behebung führt Letzteres.

10. Schnellübersicht zur Unterscheidung

Hier sind in der Praxis häufige Muster zusammengefasst.

Beobachtung Möglichkeit Als Nächstes zu betrachten
Nur Total Allocated wächst Normale Zuweisung oder übermäßige Zuweisung Zuweisungsrate, GC-Anzahl, CPU, dotnet-trace
Working Set wächst, GC Heap stabil Nativer Speicher, JIT, Stack, Festhalten auf OS-Seite Thread-Anzahl, Handle-Anzahl, native Tools, externe Bibliotheken
GC Heap wächst nur unter Last und kehrt nach Stopp zurück GC-Wartezeit, temporäre Zuweisung Gen 2 / LOH nach Stoppen der Last, GC-Anzahl
Gen 2 wächst nach der GC kontinuierlich weiter Festhalten langlebiger Objekte dumpheap -stat, gcroot
LOH wächst kontinuierlich weiter Große Arrays, Puffer, Fragmentierung, riesige Strings System.Byte[], System.Char[], LOH, Free-Bereich
System.String ist groß String-Cache, JSON, Logging, Dictionary-Keys Den eigenen Typ suchen, der die Strings festhält
System.Byte[] ist groß Puffer, Serialisierung, Bilder, Kompression, Kommunikation Besitzender Typ, fehlende ArrayPool-Rückgabe, native Interop
Task wächst Nicht abschließende asynchrone Verarbeitung, Warteschlange async-Synchronisation, Cancellation, Channel, Queue
Timer wächst Fehlende Timer-Freigabe Dispose, Abmeldung, langlebiger Dienst
CancellationTokenSource wächst Fehlende CTS-Freigabe, zu viele verknüpfte Tokens Dispose, Verknüpfung lösen, Stelle der Timeout-Erzeugung
EventHandler oder Delegate bleibt bestehen Fehlende Abmeldung von Events Lebensdauerunterschied zwischen Publisher und Subscriber
Finalization Queue wächst Fehlendes Dispose, verstopfter Finalizer finalizequeue, Finalizer-Thread
Viele gepinnte Handles Gepinnte Puffer, native Interop gchandles, POH, Stellen des Pinnings

11. Häufige Leck-Formen

Es folgen sieben Muster, die Sie nicht der Reihe nach lesen müssen. Steigen Sie bei der Zeile ein, die dem von Ihnen beobachteten Symptom am nächsten kommt.

Abschnitt Muster Typisches Symptom Zuerst zu betrachtende Kennzahl
11.1 static-Collection Wächst proportional zur Anzahl der Operationen, kehrt auch nach Stoppen der Last nicht zurück Gen 2. Ob gcroot ein static-Feld zeigt
11.2 Unbegrenzter Cache Wächst proportional zur Laufzeit. Kehrt nach einem Neustart zurück Gen 2. Anzahl der Cache-Einträge und Wachstum von System.String
11.3 Fehlende Abmeldung von Events Wächst bei jedem Öffnen und Schließen eines Bildschirms oder Scopes Count des betreffenden ViewModel- oder Handler-Typs. gcroot über den Delegate
11.4 Fehlende Timer-Freigabe Ein eigentlich kurzlebig gedachtes Objekt verschwindet nicht, auch der Callback läuft weiter Count von System.Threading.Timer oder TimerQueueTimer
11.5 Fehlende Freigabe von IDisposable GC Heap ist stabil, aber Handle-Anzahl oder Prozessspeicher wachsen Handle-Anzahl, Finalization Queue, Working Set
11.6 AsyncLocal oder Kontext-Festhalten Nach Abschluss der Anfrageverarbeitung bleibt das DTO bestehen gcroot über die async-State-Machine
11.7 Falsch gewählte DI-Lebensdauer Wächst proportional zur Anzahl der Anfragen gcroot ausgehend vom Singleton-Typ

Die Spalte „Zuerst zu betrachtende Kennzahl“ nutzen Sie zusammen mit der Schnellübersicht aus Kapitel 10. Kapitel 10 ist die Tabelle „von der Beobachtung zur Möglichkeit eingrenzen“, diese Tabelle die Tabelle „vom Muster zur zu prüfenden Kennzahl zurückgehen“.

11.1 static-Collection

Das ist die anschaulichste Form.

public static class CustomerStore
{
    private static readonly List<Customer> Customers = new();

    public static void Add(Customer customer)
    {
        Customers.Add(customer);
    }
}

In diesem Code bleibt ein zu Customers hinzugefügter Customer bestehen, solange der Prozess läuft. Auch wenn eine nur vorübergehende Speicherung beabsichtigt war, sammelt die GC nicht ein, solange die Referenz von static aus besteht.

Die Richtung der Korrektur hängt vom Verwendungszweck ab.

  • Eine Obergrenze einführen
  • Eine Ablaufzeit einführen
  • Einen Cache-Mechanismus wie MemoryCache verwenden
  • Explizit löschen
  • static aufgeben und in einen Dienst mit passender Lebensdauer verschieben
  • Bei Persistierungszweck in eine Datenbank oder einen externen Speicher verschieben

Wichtig ist nicht die Aussage „static ist schlecht“, sondern zu verstehen, dass in static Abgelegtes langlebig wird, und es entsprechend bewusst einzusetzen.

11.2 Unbegrenzter Cache

Ein Cache belegt absichtlich Speicher, deshalb ist ein Wachstum gemäß Design kein Leck. Ein Cache ohne Obergrenze oder Ablaufzeit wird jedoch faktisch zu einem Speicherleck.

public sealed class ReportCache
{
    private readonly Dictionary<string, Report> _cache = new();

    public Report GetOrCreate(string userId, DateTime date)
    {
        var key = $"{userId}:{date:O}";

        if (_cache.TryGetValue(key, out var report))
        {
            return report;
        }

        report = BuildReport(userId, date);
        _cache[key] = report;
        return report;
    }
}

In diesem Beispiel wächst der Cache immer weiter, wenn die Kombination aus userId und date immer weiter wächst.

Besonders gefährlich ist es, wenn der Key folgende Werte enthält:

  • Request-ID
  • Aktuelle Uhrzeit
  • GUID
  • Session-ID
  • Ein Benutzereingabe-String, der nicht normalisiert wurde
  • Eine SQL- oder Suchbedingung, die unverändert als String verwendet wird

Für einen Cache sollten folgende Bedingungen im Voraus festgelegt werden.

Bedingung Beispiel
Höchstanzahl Bis zu 10.000 Einträge
Höchstgröße Bis zu 256 MB
Ablaufzeit 30 Minuten seit dem letzten Zugriff
Absolute Ablaufzeit 6 Stunden seit Erstellung
Löschbedingung Mandant gelöscht, Benutzer gelöscht, Konfiguration geändert
Zu überwachende Werte Anzahl, geschätzte Größe, Trefferquote, Verdrängungsanzahl

Nicht „weil es ein Cache ist, darf er wachsen“, sondern „bis wohin darf er wachsen“ muss festgelegt werden.

11.3 Fehlende Abmeldung von Events

Ein Event wird zum Leck, wenn ein langlebiger Publisher weiterhin einen kurzlebigen Subscriber referenziert.

public sealed class OrderViewModel
{
    private readonly OrderService _service;

    public OrderViewModel(OrderService service)
    {
        _service = service;
        _service.OrderChanged += OnOrderChanged;
    }

    private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
    {
        // ViewModel aktualisieren
    }
}

Ist OrderService ein Singleton und wird OrderViewModel je Bildschirm neu erstellt, referenziert das Event von OrderService das OrderViewModel weiterhin. Wird der Bildschirm geschlossen, ohne die Abmeldung durchzuführen, bleibt das ViewModel bestehen.

Ein Korrekturbeispiel:

public sealed class OrderViewModel : IDisposable
{
    private readonly OrderService _service;

    public OrderViewModel(OrderService service)
    {
        _service = service;
        _service.OrderChanged += OnOrderChanged;
    }

    public void Dispose()
    {
        _service.OrderChanged -= OnOrderChanged;
    }

    private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
    {
        // ViewModel aktualisieren
    }
}

In gcroot kann das als Referenz über einen Delegate oder Event-Handler sichtbar werden.

Dieses Muster tritt häufig bei WPF, WinForms, langlebigen Diensten, Message-Brokern und Event-Aggregatoren auf.

11.4 Fehlende Timer-Freigabe

Auch System.Threading.Timer, PeriodicTimer oder Reactive-Extensions-Subscriptions bleiben bestehen, wenn sie nicht freigegeben werden.

public sealed class PollingWorker
{
    private readonly Timer _timer;

    public PollingWorker()
    {
        _timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
    }

    private void Poll()
    {
        // Polling
    }
}

Ist dieser PollingWorker als vorübergehendes Objekt gedacht, braucht es ein Design, das den Timer freigibt.

public sealed class PollingWorker : IDisposable
{
    private readonly Timer _timer;

    public PollingWorker()
    {
        _timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
    }

    public void Dispose()
    {
        _timer.Dispose();
    }

    private void Poll()
    {
        // Polling
    }
}

Ein Timer hält einen Callback-Delegate, über den sich die Referenz bis zum Zielobjekt fortsetzen kann.

11.5 Fehlende Freigabe von IDisposable

Fehlende Freigabe von IDisposable zeigt sich nicht zwangsläufig als Leck im Managed Heap.

Sie kann als Ressourcenproblem bei Dateien, Sockets, DB-Verbindungen, nativen Handles oder Puffern auftreten.

public async Task<string> ReadAsync(string path)
{
    var stream = File.OpenRead(path);
    using var reader = new StreamReader(stream);
    return await reader.ReadToEndAsync();
}

In diesem Beispiel schließt StreamReader den stream mit, sodass es oft nicht zu einem größeren Problem wird — bei Code mit unklarer Eigentümerschaft kommt es aber zu Lecks.

Grundsätzlich sollten Sie die Eigentümerschaft mit using / await using klar festlegen.

public async Task<string> ReadAsync(string path)
{
    await using var stream = File.OpenRead(path);
    using var reader = new StreamReader(stream);
    return await reader.ReadToEndAsync();
}

Fehlendes Dispose zeigt sich unter anderem so:

  • Die Handle-Anzahl wächst
  • Sockets wachsen an
  • Dateien werden nicht geschlossen
  • Der native Speicher wächst
  • Die Finalization Queue wächst
  • Der GC Heap ist stabil, aber der Prozessspeicher wächst

In diesem Fall reicht dumpheap allein nicht aus. Betrachten Sie auch Handles und Sockets des Betriebssystems sowie den Zustand externer Bibliotheken.

11.6 AsyncLocal oder Kontext-Festhalten

AsyncLocal<T> ist praktisch, kann aber lange bestehen bleiben, wenn der enthaltene Wert groß ist.

Ein kleiner Wert wie eine Korrelations-ID für Logging bereitet selten Probleme. Legen Sie aber Benutzerinformationen, den Anfrage-Body, ein großes DTO oder einen DB-Kontext hinein, führt das zu unbeabsichtigtem Festhalten.

public static class RequestContext
{
    public static readonly AsyncLocal<RequestInfo?> Current = new();
}

AsyncLocal folgt dem asynchronen Ablauf und ist daher mitunter schwerer zu finden als ein einfaches static-Feld.

Prüfen Sie, ob sich das Hineingelegte klein und eindeutig halten und bei Nichtbedarf auf null zurücksetzen lässt.

11.7 Falsch gewählte DI-Lebensdauer

Bei DI in ASP.NET Core und Ähnlichem unterscheiden sich die Lebensdauern von Singleton, Scoped und Transient.

Hält ein langlebiges Singleton Daten je Anfrage fest, kann das Objekt auch nach Abschluss der Anfrage bestehen bleiben.

public sealed class AuditBuffer
{
    private readonly List<RequestAudit> _items = new();

    public void Add(RequestAudit item)
    {
        _items.Add(item);
    }
}

Ist dies ein Singleton, hat _items dieselbe Lebensdauer wie die Anwendung.

Ist eine Pufferung als Design gewollt, sind Obergrenze, Versand, Löschung und Backpressure erforderlich. Handelt es sich nur um „vielleicht später mal ansehen“, sollte stattdessen in Logs oder externen Speicher geschrieben werden.

12. Der LOH wird besonders leicht missverstanden

LOH steht für Large Object Heap. In .NET werden große Objekte in einem anderen Heap abgelegt als gewöhnliche kleine Objekte. Ein typisches Beispiel sind große Arrays.

var buffer = new byte[1024 * 1024 * 10]; // 10 MB

Beim LOH treten häufig drei Probleme auf:

  1. Große Objekte werden häufig erzeugt
  2. Große Objekte werden lange festgehalten
  3. Durch Erzeugen und Verwerfen großer Objekte entsteht Fragmentierung

Nur weil der LOH wächst, ist das nicht sofort ein Leck. Bei einem Design mit Wiederverwendung großer Puffer kann er bis zu einer bestimmten Größe wachsen und sich dann stabilisieren, und selbst wenn die GC einsammelt, sinkt der Working Set nicht zwingend sofort.

Folgende Zustände sollten Sie jedoch als verdächtig behandeln:

  • System.Byte[] wächst mit jeder Operation
  • System.Char[] oder riesige String-Objekte wachsen
  • Nach Bild-, PDF-, Excel-, ZIP-, Verschlüsselungs- oder Kompressionsverarbeitung kehrt es nicht zurück
  • Über ArrayPool<T>.Rent gemietete Arrays werden nicht zurückgegeben
  • Eine große Antwort wird komplett im Speicher gehalten
  • MemoryStream.ToArray() wird häufig verwendet

Verwenden Sie ArrayPool<T>, geben Sie es unbedingt zurück.

var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(1024 * 1024);

try
{
    // Puffer verwenden
}
finally
{
    pool.Return(buffer);
}

Auch wenn Sie es an den Pool zurückgeben, sinkt der Prozessspeicher nicht zwingend sofort. Der Pool kann Speicher zur Wiederverwendung zurückhalten.

Auch hier ist zu betrachten: „Wächst es kontinuierlich weiter?“, „Gibt es eine Obergrenze?“, „Wird es wiederverwendet?“

13. Wie man gcroot liest

gcroot zeigt an, von wo aus ein Objekt referenziert wird.

Typische Wurzeln sind in dieser Tabelle zusammengefasst.

Wurzel Bedeutung
static field Wird von einem static-Feld des Typs referenziert
local variable / stack Wird vom Stack des laufenden Threads referenziert
GC handle Wird über GCHandle, Pinning, Delegate, Interop usw. referenziert
finalization queue Wird für die anstehende Finalisierung festgehalten
thread / async state machine Wird von einer laufenden oder wartenden asynchronen Verarbeitung festgehalten

Bei der Untersuchung ist besonders auf den Lebensdauerunterschied zu achten.

Langlebiges Objekt
  -> eigentlich kurzlebiges Objekt

Zeigt sich diese Form, ist das ein Leck-Kandidat.

Verdächtig ist zum Beispiel:

SingletonService
  -> List<RequestContext>
  -> RequestContext
  -> LargeDto

SingletonService lebt für die gesamte Anwendung. Sammelt sich darin ein RequestContext je Anfrage an, muss das Design überarbeitet werden.

Andererseits können folgende Wurzeln je nach Zeitpunkt normal sein.

Thread stack
  -> Lokale Variable der Controller-Action
  -> RequestDto

Während der Anfrageverarbeitung ist es selbstverständlich, dass eine lokale Variable noch besteht.

Deshalb kommt es beim Dump auf den Zeitpunkt an.

Nehmen Sie Dumps nicht nur unter Last, sondern auch nach Stoppen der Last, nachdem die Warteschlange leer ist, und nach einer gewissen Leerlaufzeit — das erleichtert die Beurteilung.

14. „Nach einer erzwungenen GC gesunken“ heißt nicht „gelöst“

Bei der Untersuchung haben Sie GC.Collect() aufgerufen, und der Speicher ist gesunken. Der Gedanke „dann sollte man GC.Collect() einfach regelmäßig ausführen“ ist an dieser Stelle gefährlich.

Eine erzwungene GC beseitigt nicht die eigentliche Ursache. Sie sammelt lediglich zuvor noch nicht eingesammelte Objekte an Ort und Stelle ein.

Ist eine hohe Zuweisungsrate das Problem, verschlechtert eine erzwungene GC durch mehr Pausenzeit sogar die Leistung. Liegt ein echtes Leck vor, werden Objekte mit verbleibenden Referenzen auch durch eine erzwungene GC nicht eingesammelt.

Bei der Untersuchung ist auf folgenden Unterschied zu achten:

Nach erzwungener GC Beurteilung
Sinkt deutlich, Basislinie danach stabil Hauptursache ist GC-Wartezeit oder temporäre Zuweisung
Sinkt etwas, aber der Boden steigt bei jeder Wiederholung Ein Teil überlebt. Leck-Kandidat
Sinkt kaum Wird weiterhin referenziert, oder die Hauptursache liegt außerhalb des GC-Heaps
GC Heap sinkt, aber Working Set sinkt nicht Möglicherweise Festhalten auf OS-/GC-Segment-/nativer Seite

Bevor Sie GC.Collect() regelmäßig in der Produktion ausführen, ermitteln Sie unbedingt zuerst, was tatsächlich wächst.

15. Das in der Praxis verwendete Untersuchungsverfahren

Ab hier folgt eine Zusammenfassung als Verfahren für die tatsächliche Untersuchung.

15.1 Das Reproduktionsszenario festlegen

Legen Sie zuerst die Untersuchungsbedingungen fest.

Ziel:           /api/report/export
Operation:      100-mal unter denselben Bedingungen ausführen
Messintervall:  5 Sekunden
Beobachtungsdauer: 5 Minuten Aufwärmen + 10 Minuten Last + 5 Minuten Leerlauf
Umgebung:       Staging / Release-Build / produktionsnahe Konfiguration

Bei einer Speicheruntersuchung lässt sich nichts beurteilen, wenn Sie jedes Mal etwas anderes tun. Legen Sie fest, „wodurch“ der Speicher gewachsen ist.

15.2 Eine Basislinie erfassen

Nehmen Sie nicht den Zeitpunkt direkt nach dem Start, sondern nach dem Aufwärmen als Basislinie.

Der Grund: Direkt nach dem Start gibt es einmalige Anstiege durch Folgendes.

  • JIT
  • Aufbau des DI-Containers
  • Laden der Konfiguration
  • Erste DB-Verbindung
  • Erste TLS-/HTTP-Verbindung
  • Erzeugung der JSON-Serialisierer-Metadaten
  • Initialisierung von Razor / Templates
  • Initialisierung von Logger und Metriken

Die Reihenfolge ist wie folgt.

1. Anwendung starten
2. Health-Check oder eine repräsentative API mehrfach aufrufen
3. 1 bis 5 Minuten warten
4. Als Basislinie counters und dump erfassen

15.3 Während der Last counters erfassen

dotnet-counters collect \
  --process-id <PID> \
  --refresh-interval 5 \
  --format csv \
  --output report-export-counters.csv \
  --counters System.Runtime

Führen Sie parallel die Reproduktionsoperation durch. Zu betrachten ist die Form des Graphen.

Normal wirkende Form:
  Wächst unter Last
  Schwankt durch die GC auf und ab
  Kehrt nach Stoppen der Last zurück
  Die Basislinie wächst nicht kontinuierlich weiter

Verdächtige Form:
  Wächst proportional zur Anzahl der Operationen
  Der Boden von Gen 2 / LOH steigt
  Kehrt auch nach Stoppen der Last nicht zurück
  Steigt bei der nächsten Last noch weiter

15.4 Zwei Dumps nehmen

Nehmen Sie sie vor und nach der Last.

dotnet-dump collect --process-id <PID> --type Heap --output before.dmp
# Last aufbringen
dotnet-dump collect --process-id <PID> --type Heap --output after.dmp

Wenn möglich, nehmen Sie auch einen nach dem Stoppen der Last.

# Nach Stoppen der Last, wenn die Warteschlange leer ist und nach einer Wartezeit
dotnet-dump collect --process-id <PID> --type Heap --output idle-after.dmp

Beim Vergleich ist nicht nur before und after, sondern auch idle-after wichtig.

Wächst etwas unter Last, kehrt aber nach dem Leerlauf zurück, liegt möglicherweise kein Leck vor.

15.5 Die gewachsenen Typen betrachten

dotnet-dump analyze after.dmp
> dumpheap -stat

Betrachten Sie die before-Seite auf dieselbe Weise. Auch manuell ist das möglich, vergleichen Sie zunächst die obersten Typen.

Hier die zu betrachtenden Punkte:

  • Wachsen Typen aus dem eigenen Namespace?
  • Steckt hinter System.String ein eigener Typ?
  • Wer hält System.Byte[]?
  • Wachsen List<T> oder Dictionary<TKey,TValue>?
  • Wachsen Task oder die async-State-Machine?
  • Wachsen Timer oder CancellationTokenSource?

15.6 Die Referenzquelle betrachten

Greifen Sie die Adresse eines Kandidatenobjekts auf und führen Sie gcroot aus.

> dumpheap -type MyApp.Models.ReportResult
> gcroot <OBJECT_ADDRESS>

Suchen Sie anhand des gcroot-Ergebnisses den festhaltenden übergeordneten Bereich.

MyApp.Services.ReportCache
  -> Dictionary<string, ReportResult>
  -> ReportResult

An diesem Punkt wird sichtbar, was Gegenstand einer Code-Review sein sollte.

  • Ist ReportCache ein Singleton?
  • Gibt es eine Obergrenze?
  • Wird gelöscht?
  • Wächst die Anzahl der Keys kontinuierlich weiter?
  • Ist ReportResult nicht zu groß?
  • Sollte statt eines Caches eine DB oder Datei verwendet werden?

16. Wann man dotnet-trace einsetzt

dotnet-dump eignet sich für einen Schnappschuss zu einem bestimmten Zeitpunkt, um zu sehen, „was im Ergebnis übrig bleibt“. Wollen Sie hingegen sehen, „wann und wo in großem Umfang zugewiesen wird“, verwenden Sie dotnet-trace.

Erfassen Sie zum Beispiel eine Trace mit GC-bezogenen Ereignissen.

dotnet-trace collect \
  --process-id <PID> \
  --duration 00:00:01:00 \
  --clrevents gc+gchandle \
  --clreventlevel informational \
  --output gc-trace.nettrace

Wollen Sie auch das Sampling der Zuweisungen betrachten, steigt das Ereignisvolumen — beginnen Sie deshalb in der Testumgebung mit kurzer Dauer.

dotnet-trace collect \
  --process-id <PID> \
  --duration 00:00:00:30 \
  --clrevents gc+gcsampledobjectallocationhigh \
  --clreventlevel informational \
  --output allocation-trace.nettrace

Eine Trace hilft aus einem anderen Blickwinkel als ein Dump.

Was Sie sehen wollen Geeignetes Werkzeug
Was übrig bleibt dump / gcdump
Wer referenziert dump + gcroot
Wann in großem Umfang zugewiesen wurde trace
Wann die GC lief counters / trace
Ob die Pausenzeit ein Problem ist counters / trace

Bei der Leck-Untersuchung ist es effizient, zunächst mit dump zu betrachten, „was übrig bleibt“, und bei Bedarf mit trace zu betrachten, „wo es erzeugt wird“.

17. Prüfmetriken im Code ausgeben

Eine vollwertige Diagnose sollte mit externen Werkzeugen erfolgen, aber ein einfaches Diagnose-Log in der Anwendung selbst kann hilfreich sein.

Ein Beispiel: GC-Informationen über einen Administrator-Endpunkt oder ein regelmäßiges Log ausgeben.

public static class GcDiagnostics
{
    public static object Snapshot()
    {
        var info = GC.GetGCMemoryInfo();

        return new
        {
            TotalMemory = GC.GetTotalMemory(forceFullCollection: false),
            HeapSizeBytes = info.HeapSizeBytes,
            FragmentedBytes = info.FragmentedBytes,
            MemoryLoadBytes = info.MemoryLoadBytes,
            HighMemoryLoadThresholdBytes = info.HighMemoryLoadThresholdBytes,
            Gen0Collections = GC.CollectionCount(0),
            Gen1Collections = GC.CollectionCount(1),
            Gen2Collections = GC.CollectionCount(2)
        };
    }
}

Allein aus dieser Information lässt sich kein Leck feststellen. Im Störungsfall erleichtert sie aber folgende Einschätzungen.

  • Steigt Gen 2 sprunghaft an?
  • Wächst HeapSize?
  • Wächst FragmentedBytes?
  • Ist die Differenz zwischen TotalMemory und Prozessspeicher groß?
  • Hat sich die Tendenz seit dem letzten Deployment geändert?

Nehmen Sie das ins Anwendungslog auf, achten Sie darauf, es nicht zu übertreiben. Häufige, aufwendige Diagnoseaufrufe werden selbst zur Last.

18. Kriterien, um endgültig „Speicherleck“ festzustellen

Am Ende der Untersuchung sollten Sie es in dieser Form erklären können. Für den Bericht sind hier zuerst die auszufüllenden Felder aufgelistet.

Feld Inhalt
Symptom Wer ist betroffen. Bei welcher Operation wächst es um wie viel
Beobachtungsbedingungen Umgebung, Build-Konfiguration, Datenmenge, Anzahl der Ausführungen, Aufwärmzeit, Beobachtungsintervall, verwendete Werkzeuge und Versionen
Beobachtung Wie sich die counters-Werte entwickelt haben. Working Set und GC Heap getrennt aufführen
Vergleich Welcher Typ im before-/after-Dump um wie viele Einträge gewachsen ist
Referenzquelle Der mit gcroot sichtbare Festhaltungspfad
Ursache Welches Design im Code dieses Festhalten erzeugt
Maßnahme Was geändert wird. Wie die Wirkung gemessen wird
Offene Punkte Falls nötig. Was mit dieser Beobachtung nicht erklärt werden konnte, und was als Nächstes zu betrachten ist

Besonders wichtig ist es, die Beobachtungsbedingungen nicht zu vergessen. Fehlen sie, lässt sich die Nachmessung nach der Korrektur nicht vergleichen, und die Prüfung aus Kapitel 20 verliert ihren Sinn.

Füllt man dieses Schema aus, ergibt sich zum Beispiel Folgendes.

Symptom:
  Nach 100-facher Ausführung von /api/report/export bleibt der GC Heap
  auch nach Stoppen der Last um 300 MB erhöht und kehrt nicht zurück.

Beobachtungsbedingungen:
  Staging-Umgebung / Release-Build / produktionsnahe Konfiguration und Datenmenge.
  Nach 5 Minuten Aufwärmen 100-mal unter denselben Bedingungen ausgeführt,
  danach 5 Minuten Leerlauf.
  dotnet-counters im 5-Sekunden-Intervall erfasst.

Beobachtung:
  dotnet-counters zeigte, dass die Heap-Größe von Gen 2 proportional zur
  Anzahl der Operationen wuchs.
  Nicht nur der Working Set, auch der GC Heap wuchs.

Vergleich:
  Beim Vergleich von before.dmp und after.dmp war
  MyApp.Models.ReportResult um 12.000 Einträge gewachsen.

Referenzquelle:
  Mit gcroot als Referenz von MyApp.Services.ReportCache._items festgestellt.

Ursache:
  ReportCache ist ein Singleton, dessen Key aus Benutzer-ID + aktueller
  Uhrzeit besteht, ohne Löschung, Ablaufzeit oder Obergrenze.

Maßnahme:
  Auf MemoryCache umgestellt und Größenobergrenze sowie Ablaufzeit
  festgelegt.
  Die Anzahl der Cache-Einträge wurde zu einer Metrik gemacht.

Lässt sich das so erklären, entsteht kein bloßes „der Speicher wächst“, sondern ein Bericht, der Reproduktionsbedingungen, Messwerte, gewachsenen Typ, Referenzquelle, Ursache und Maßnahme miteinander verbindet.

19. Hinweise bei der Untersuchung

19.1 Im Release-Build betrachten

Ein Debug-Build kann sich durch Optimierung, Lebensdauer lokaler Variablen und Debug-Informationen anders verhalten als der reale Betrieb.

Führen Sie eine produktionsnahe Untersuchung im Release-Build, mit betriebsnaher Konfiguration und ähnlicher Datenmenge durch.

19.2 Nicht allein anhand des Zeitpunkts direkt nach dem Start beurteilen

Direkt nach dem Start wächst der Speicher durch verschiedene Initialisierungen.

Nehmen Sie die Basislinie nach dem Aufwärmen und betrachten Sie das Wachstum ab diesem Punkt.

19.3 Nicht anhand eines einzelnen Dumps einen Täter benennen

Ein Typ weit oben im Heap ist nicht zwangsläufig der Täter.

System.String und System.Byte[] erscheinen bei vielen Anwendungen groß.

Wichtig ist, ob über die Zeit ein Wachstum stattfindet, und wer den Typ hält.

19.4 Ein Dump kann vertrauliche Informationen enthalten

Ein Speicher-Dump kann Anfragen, Anmeldeinformationen, Connection-Strings, personenbezogene Daten und Geschäftsdaten enthalten.

Legen Sie Speicherort, Mitnahme, Weitergabe und Löschregeln fest.

19.5 In Containern wird das Erfassen eines Dumps zum Risiko

Bei knappem Speicherlimit des Containers kann der zusätzliche Speicher- oder Pagein-Bedarf beim Erfassen eines Dumps zu einem OOM-Kill führen.

Testen Sie es vor der Erfassung im Produktionscontainer zunächst im Staging und prüfen Sie Limitwert, Festplattenkapazität, Berechtigungen und PID-Namespace.

19.6 Es gibt auch Lecks außerhalb des GC Heaps

Nur weil es sich um eine .NET-Untersuchung handelt, zeigt sich nicht alles im GC-Heap.

Bei folgenden Problemen kann der Prozessspeicher wachsen, obwohl der GC Heap stabil ist.

  • Native Bibliotheken
  • P/Invoke
  • COM
  • Bildverarbeitung
  • Kompressionsbibliotheken
  • Verschlüsselung
  • DB-Treiber
  • Sockets
  • Marshal.AllocHGlobal
  • NativeMemory.Alloc
  • Zu viele Threads

In diesem Fall reicht dumpheap von dotnet-dump allein nicht aus. Es braucht Diagnose auf Betriebssystemseite sowie Metriken zu externen Bibliotheken, Handles, Threads und nativem Speicher.

19.7 Was vor dem Erfassen eines Dumps in der Produktion mit den Beteiligten zu klären ist

Das Erfassen eines Dumps in der Produktion ist, bevor es ein technischer Vorgang ist, eine Aufgabe, die Abstimmung erfordert. Überspringt man das mit einem bloßen „bitte lassen Sie mich zur Untersuchung einen Dump nehmen“, wird das meist gestoppt.

Ordnen Sie zunächst, was als Auswirkung erklärt werden muss.

  • Das Erfassen eines Dumps ist für den Zielprozess ein aufwendiger Vorgang; während der Erfassung kann es so wirken, als würden Antworten ausbleiben. Wie lange das dauert, hängt von Heap-Größe, Festplattengeschwindigkeit und Umgebung ab — führen Sie deshalb einmal denselben Ablauf im Staging durch und messen Sie ihn tatsächlich.
  • Dauert das Ausbleiben der Antworten zu lange, kann das Health-Check-Timeouts, das Herausnehmen aus dem Load-Balancer oder ein Failover des Clusters auslösen.
  • Full- und Heap-Dumps werden je nach Speicherverbrauch des Prozesses groß. Prüfen Sie zuerst den freien Festplattenplatz am Zielort.
  • In Containern kann das mit dem Erfassen einhergehende Pagein das Speicherlimit überschreiten und den Container zwangsweise beenden (19.5).
  • Ein Dump kann personenbezogene Daten oder Anmeldeinformationen enthalten (19.4).

Legen Sie darüber hinaus vor der Erfassung Folgendes fest.

Zu klärender Punkt Beispiel
Wer genehmigt Der Serviceverantwortliche und die IT-Abteilung. Beide Zustimmungen vorab einholen
Wann erfasst wird Außerhalb der Geschäftszeiten oder ein Zeitfenster, in dem eine kurzzeitige Verzögerung akzeptabel ist
Wohin geschrieben wird Eine lokale Festplatte mit ausreichend freiem Platz. Nicht direkt in einen Netzwerkordner schreiben
Wer Zugriff hat Zugriffsrechte auf den Ablageort auf das Untersuchungsteam beschränken
Wann gelöscht wird Löschfrist nach Abschluss der Untersuchung vorab festlegen und dokumentieren
Was bei einem Fehlschlag passiert Neustartverfahren, falls keine Antwort zurückkehrt, und wer entscheidet
Vorabtest Denselben Ablauf einmal im Staging durchführen und Dauer sowie Dateigröße festhalten

Übermitteln Sie die Anfrage nicht mündlich, sondern fassen Sie diesen Inhalt zuverlässig auf einer Seite zusammen. Ein Beispielformat:

Zweck:          Ursache ermitteln, warum der Speicher nach Ausführung
                von /api/report/export nicht zurückgeht
Vorgehen:       dotnet-dump collect --type Heap vor und nach der Last
                zweimal erfassen
Auswirkung:     Während der Erfassung verzögert sich die Antwort des
                Zielprozesses. Gemessene Werte aus dem Staging liegen
                als Anhang bei
Zeitfenster:    Außerhalb der Geschäftszeiten. Ein Zeitfenster, in dem
                Verantwortlicher und IT-Abteilung anwesend sein können
Ablageort:      Lokale Festplatte des Zielservers. Freier Speicherplatz
                vorab geprüft
Enthaltene Daten: Kann Anfragedaten aus dem Speicher, Connection-Strings
                usw. enthalten
Umgang:         Zugriffsrechte auf den Ablageort nur für das
                Untersuchungsteam. Keine Mitnahme
Löschung:       Löschung spätestens einen Monat nach Abschluss der
                Untersuchung, mit Löschprotokoll
Rückfall:       Falls nach der Erfassung keine Antwort zurückkehrt, wird
                der Prozess neu gestartet

Sind „wofür“, „wie lange es stillsteht“ und „wo es abgelegt und wann es gelöscht wird“ vollständig, fällt es der entscheidenden Seite leichter, eine Freigabe zu erteilen. Bleibt das dagegen unklar, kommt die Untersuchung selbst ins Stocken.

20. Überprüfung nach der Behebung

Haben Sie eine leck-verdächtige Stelle behoben, messen Sie mit demselben Verfahren erneut.

Vor der Behebung:
  Nach 100 Ausführungen +300 MB bei Gen 2
  ReportResult +12.000 Einträge

Nach der Behebung:
  Nach 100 Ausführungen bleibt Gen 2 innerhalb von +20 MB stabil
  ReportResult kehrt nach Stoppen der Last zur Basislinie zurück
  Die Anzahl der Cache-Einträge bleibt bei der Obergrenze von 1.000 stabil

Vergleichen Sie bei der Verifizierung der Behebung unbedingt unter gleichen Bedingungen.

  • Gleiche Datenmenge
  • Gleiche Anzahl
  • Gleiche Lastdauer
  • Gleiches Aufwärmen
  • Gleiches Beobachtungsintervall
  • Gleiche Werkzeuge

Eine Speicheruntersuchung überzeugt nicht, wenn der Vergleich vor und nach der Behebung schwach ausfällt.

21. Zusammenfassung

Wächst der Speicher in .NET, sollten Sie nicht sofort von einem Leck ausgehen, sondern in dieser Reihenfolge eingrenzen.

  1. Nicht allein anhand von Working Set / RSS urteilen
  2. Mit dotnet-counters GC Heap, Gen 2, LOH und GC-Anzahl betrachten
  3. Über die Zeit vergleichen: unter Last, nach Stoppen der Last
  4. Mit dotnet-gcdump oder dotnet-dump den gewachsenen Typ betrachten
  5. Mit gcroot die Referenzquelle betrachten
  6. static, Cache, Events, Timer, DI-Lebensdauer, asynchronen Kontext prüfen
  7. Ist der GC Heap stabil, auch nativen Speicher oder Probleme auf OS-Seite vermuten

Der Unterschied zwischen „es wurde nur noch nicht eingesammelt“ und „es liegt ein Speicherleck vor“ entscheidet sich letztlich an der Referenz.

Wird ein nicht mehr benötigtes Objekt nicht referenziert, sammelt die GC es zum gegebenen Zeitpunkt ein. Wird es trotz Nichtbedarfs weiterhin referenziert, kann die GC es nicht einsammeln.

Das Ziel der Untersuchung lautet also:

Was wächst.
Was auch nach jeder GC übrig bleibt.
Wer referenziert es.
Ob diese Referenz vom Design her nötig ist.

Ist das geklärt, lassen Sie sich nicht mehr von der Speicherkurve treiben, sondern können es in einen konkreten Korrekturpunkt im Code übersetzen.

Referenzen

Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.

Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

Häufige Fragen

Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.

Ist es ein Speicherleck, wenn der Speicherverbrauch einer .NET-Anwendung wächst?
Dass der Speicherverbrauch eines Prozesses wächst, ist nicht dasselbe wie ein Speicherleck. In .NET arbeitet die Garbage Collection anhand von Zuweisungsverhalten und Heap-Schwellenwerten, sodass nicht mehr benötigte Objekte mitunter einfach noch nicht eingesammelt wurden oder das Betriebssystem den Speicher nach der GC nicht sofort zurückfordert. Zu prüfen sind drei Punkte: ob der auch nach der GC überlebende Speicher wächst, welcher Typ wächst, und wer das jeweilige Objekt referenziert.
Welche Tools eignen sich, um ein Speicherleck in .NET zu untersuchen?
Zunächst betrachten Sie mit dotnet-counters die Tendenzen von Working Set, GC Heap, Gen 2/LOH und der Anzahl der GC-Läufe. Als Nächstes nehmen Sie mit dotnet-gcdump GC-Dumps vor und nach der Last und vergleichen Count und Size je Typ, um den gewachsenen Typ zu identifizieren. Zuletzt erstellen Sie mit dotnet-dump einen Heap-Dump und verfolgen mit dumpheap -stat und gcroot bis zur Referenzquelle, „warum das Objekt nicht eingesammelt wird“. Entscheidend ist nicht ein einzelner Messwert, sondern der Vergleich über die Zeit unter gleichen Bedingungen.
Was sind häufige Muster für Speicherlecks in .NET?
Typische Beispiele sind: fortlaufendes Hinzufügen zu einer static-Collection, unbegrenzte Caches ohne Höchstgrenze oder Ablaufzeit, fehlende Abmeldung von Event-Handlern bei einem langlebigen Publisher, nicht freigegebene Timer, nicht freigegebene IDisposable-Instanzen sowie eine falsch gewählte DI-Lebensdauer, bei der ein Singleton Daten pro Anfrage festhält. Ein .NET-Leck lässt sich klarer verstehen, wenn man es weniger als „vergessene Freigabe“ und mehr als „unbeabsichtigtes Festhalten“ eines eigentlich nicht mehr benötigten, aber weiterhin referenzierten Objekts betrachtet.
Löst ein regelmäßiger Aufruf von GC.Collect() das Speicherproblem?
Nein. Eine erzwungene GC sammelt lediglich noch nicht eingesammelte Objekte an Ort und Stelle ein und beseitigt nicht die eigentliche Ursache. Ist eine hohe Zuweisungsrate das Problem, verschlechtert eine erzwungene GC durch längere Pausenzeiten sogar die Leistung, und liegt ein echtes Leck vor, werden Objekte mit verbleibenden Referenzen auch durch eine erzwungene GC nicht eingesammelt. Zu Untersuchungszwecken lässt sich in einer kontrollierten Testumgebung prüfen, ob etwas auch nach einer erzwungenen GC übrig bleibt, doch bevor Sie das als Lösung in die Produktion übernehmen, sollten Sie unbedingt zuerst ermitteln, was tatsächlich wächst.

Autorenprofil

Profilseite des Artikelautors.

Go Komura

Geschäftsführer von KomuraSoft LLC

Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.

Zurück zum Blog