GC-Wartezeit von einem Speicherleck in .NET unterscheiden — Ein praktisches Verfahren, um wachsenden Speicher zu beobachten, zu vergleichen und zu belegen
· Go Komura · .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:
- Wächst der auch nach der GC überlebende Speicher?
- Welcher Typ wächst?
- 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.
flowchart TB
PROC["Vom Prozess verwendeter Speicher"] --> MANAGED["Managed-Seite<br/>Über GC Heap Size sichtbarer Bereich"]
PROC --> NATIVE["Native Seite<br/>In GC Heap Size nicht sichtbarer Bereich"]
MANAGED --> G01["Gen 0 / Gen 1<br/>Kurzlebige Zuweisungen"]
MANAGED --> G2["Gen 2<br/>Überlebende langlebige Objekte"]
MANAGED --> LOH["LOH<br/>Große Objekte ab 85.000 Byte"]
MANAGED --> POH["POH<br/>Gepinnte Objekte"]
NATIVE --> STK["Thread-Stacks"]
NATIVE --> JITC["JIT-kompilierter Code, geladene Assemblys"]
NATIVE --> INTEROP["Puffer von P/Invoke, COM, externen Bibliotheken"]
MANAGED -.wird angerechnet.-> COMMIT["Private Bytes / Commit<br/>Vom Prozess privat belegter, committeter Speicher"]
NATIVE -.wird angerechnet.-> COMMIT
COMMIT -.nur der im physischen Speicher liegende Anteil.-> WS["Working Set / RSS"]
Bei diesem Diagramm sind zwei Punkte wichtig:
- Mit
dumpheapsieht 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. - 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
MemoryCacheverwenden - 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:
- Große Objekte werden häufig erzeugt
- Große Objekte werden lange festgehalten
- 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 OperationSystem.Char[]oder riesigeString-Objekte wachsen- Nach Bild-, PDF-, Excel-, ZIP-, Verschlüsselungs- oder Kompressionsverarbeitung kehrt es nicht zurück
- Über
ArrayPool<T>.Rentgemietete 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.Stringein eigener Typ? - Wer hält
System.Byte[]? - Wachsen
List<T>oderDictionary<TKey,TValue>? - Wachsen
Taskoder die async-State-Machine? - Wachsen
TimeroderCancellationTokenSource?
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
ReportCacheein Singleton? - Gibt es eine Obergrenze?
- Wird gelöscht?
- Wächst die Anzahl der Keys kontinuierlich weiter?
- Ist
ReportResultnicht 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.AllocHGlobalNativeMemory.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- undHeap-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.
- Nicht allein anhand von Working Set / RSS urteilen
- Mit
dotnet-countersGC Heap, Gen 2, LOH und GC-Anzahl betrachten - Über die Zeit vergleichen: unter Last, nach Stoppen der Last
- Mit
dotnet-gcdumpoderdotnet-dumpden gewachsenen Typ betrachten - Mit
gcrootdie Referenzquelle betrachten - static, Cache, Events, Timer, DI-Lebensdauer, asynchronen Kontext prüfen
- 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
- Das vollständige Beispielcode-Paket zu diesem Artikel (Bibliothek, Demo, Unit-Tests) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/dotnet-gc-or-memory-leak
- .NET: Fundamentals of garbage collection
- .NET: Debug a memory leak
- .NET CLI: dotnet-counters diagnostic tool
- .NET CLI: dotnet-dump diagnostic tool
- .NET CLI: dotnet-gcdump diagnostic tool
- .NET CLI: dotnet-trace diagnostic tool
- .NET: Induced collections
- .NET: Large object heap
- .NET: Workstation and server garbage collection
- .NET Framework: Performance counters
- .NET Framework: SOS.dll (SOS Debugging Extension)
Verwandte Artikel
Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.
Was ist eine PDB (Program Database)? — Debugging-Informationen, Symbole und Source Link verstehen
Was eine PDB (Program Database) ist, was darin enthalten ist und was nicht, sowie der Zusammenhang mit Debug/Release, Portable PDB, Sourc...
Datum, Uhrzeit und Zeitzonen in Business-Apps — Von den Fallstricken von DateTime über das UTC-Speicherprinzip bis zum Testdesign
Zeitstempel verschieben sich nach einer Servermigration um neun Stunden; nur beim Auslandsbüro springt das Datum auf den Vortag zurück – ...
SQLite aus C# in Business-Apps nutzen — WAL-Modus, exklusive Sperren, Schutz vor Datenbankbeschädigung und wann sich EF Core lohnt
Ein praxisnaher Überblick zur Einbindung von SQLite in eine Windows-Businessanwendung mit Microsoft.Data.Sqlite: Verbindungszeichenfolgen...
Windows-Dienste erstellen und betreiben ── Von der Abgrenzung zur Aufgabenplanung bis zur Umwandlung eines BackgroundService in einen Dienst
Sollte ein Hintergrundprozess ein Windows-Dienst werden, oder reicht die Aufgabenplanung? Dieser Artikel ordnet die Entscheidungstabelle,...
Algebraische Datentypen in .NET Framework / .NET verwenden — Zustände und Ergebnisse mit Typen entwerfen
Wie sich algebraische Datentypen – insbesondere Summentypen und diskriminierte Vereinigungen – in .NET Framework und .NET einsetzen lasse...
Verwandte Themen
Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.
Technische Windows-Themen
Portal zu Windows-Entwicklung, Fehleranalyse und der Nutzung bestehender Assets.
Leistungen zu diesem Thema
Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.
Windows-App-Entwicklung
Geschäftsanwendungen, Geräteintegration und Kommunikationstools von den Anforderungen bis zur Umsetzung.
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.