Untersuchung eines Absturzes einer Industriekamera-Anwendung nach Langzeitbetrieb - Der Handle-Leak (Teil 1)
· Go Komura · Windows-Entwicklung, Fehleranalyse, Industriekamera, Handle-Leak, Log-Design
Wenn eine Windows-Anwendung nach langem Dauerbetrieb plötzlich abstürzt, liegt der erste Verdacht ziemlich oft bei einem Memory-Leak. Tatsächlich ist aber nicht selten ein Handle-Leak der eigentliche Übeltäter, der erst nach Wochen als Folgeschaden zutage tritt.
In diesem Artikel stellen wir einen Fall vor, in dem wir untersucht haben, warum eine Windows-Anwendung zur Steuerung einer Industriekamera nach etwa einem Monat Dauerbetrieb plötzlich abstürzte. Die Eingrenzung ergab als Ursache ein Handle-Leak auf dem Fehlerpfad rund um die Kamera-Reconnect-Logik.
Im ersten Teil fassen wir zusammen, was ein Handle-Leak ist, wie wir diesen Fall eingegrenzt haben und welche Logs zur Vorbeugung nötig sind. Im zweiten Teil geht es um Application Verifier: Windows-Testfundament für Fehlerpfade - also um den Aufbau eines Testfundaments für Fehlerpfade.
Eigennamen und einige Log-Felder sind unkenntlich gemacht, aber der grundlegende Denkansatz ist bei Windows-Anwendungen zur Gerätesteuerung ziemlich allgemein übertragbar.
Inhaltsverzeichnis
- Zuerst das Fazit (in Kürze)
- Was ist ein Handle-Leak?
- 2.1. Was hier mit „Handle“ gemeint ist
- 2.2. Warum es erst bei Langzeitbetrieb auffällt
- 2.3. Der Unterschied zum Memory-Leak
- Fallbeispiel: Eine Industriekamera-Steuerungsanwendung stürzt nach einem Monat plötzlich ab
- 3.1. Die aufgetretenen Symptome
- 3.2. Die zuerst betrachteten Kennzahlen
- 3.3. Die Stelle, die die eigentliche Ursache war
- Wie wir es eingegrenzt haben
- 4.1. Zeit sparen, statt auf eine monatelange Reproduktion zu warten
- 4.2. Die Steigung von
Handle Countbetrachten - 4.3. Die Zuordnung von
create/openundclose/disposeprüfen - 4.4. Beim Handle-Leak nicht die Absturzstelle, sondern die Leckstelle suchen
- Die Logs, die zur Vorbeugung nötig sind
- 5.1. Das Minimum, das zuerst erfasst werden sollte
- 5.2. Die tatsächlich verstärkten Logs
- 5.3. In welcher Granularität erfasst werden sollte
- Grobe Einsatzunterscheidung
- Zusammenfassung
- Quellen
1. Zuerst das Fazit (in Kürze)
- Bei einer Steuerungsanwendung, die erst nach langem Dauerbetrieb abstürzt, unbedingt nicht nur
Private Bytes, sondern auchHandle Countbeobachten - Ein Handle-Leak versteckt sich eher nicht im Normalpfad, sondern in den Pfaden von
timeout/reconnect/ Teilfehlern / early return - Die Zeile, an der es tatsächlich abstürzt, ist häufig nicht die Leckstelle, sondern die Stelle, an der später kein neues Handle mehr erzeugt werden konnte
- Die zuerst benötigten Logs sind: der Kontext von
operation/session, derhandle countdes Prozesses, dieopen/close-Zuordnung der Ressource sowie Win32-/HRESULT-/SDK-Fehler - Statt auf eine monatelange Reproduktion zu warten, ist es schneller, Verbindungsaufbau, Trennung, Reconnect und Fehlerpfade in einer kurzen Schleife mehrere tausend Mal durchzuspielen
- Der im zweiten Teil behandelte Application Verifier ist ziemlich wirksam, aber die Grundlage davor ist, mit eigenen Logs Brüche im lifetime nachvollziehen zu können
Kurz gesagt: Bei einem solchen Fall sollte man zuerst nicht betrachten, dass „es nach langer Zeit abgestürzt ist“, sondern dafür sorgen, dass sich das Wachstum der Ressourcen und die Fehlerpfade beobachten lassen.
Ein Handle-Leak trägt zu dem Zeitpunkt, an dem es entdeckt wird, meist schon das Gesicht eines Folgeschadens. Wer deshalb nur auf die Exception im Moment des Absturzes schaut, läuft leicht in eine ziemlich falsche Richtung.
2. Was ist ein Handle-Leak?
2.1. Was hier mit „Handle“ gemeint ist
Ein Handle ist hier der Bezeichner, mit dem ein Windows-Prozess auf eine OS-Ressource verweist. Dazu zählen zum Beispiel folgende.
| Kategorie | Beispiele |
|---|---|
| Kernelobjekte | event, mutex, semaphore, thread, process, waitable timer |
| I/O-Bereich | open von file, pipe, socket, device |
| Häufig bei Gerätesteuerung | interne events des Kamera-SDK, Wait-Objekte, die an eine callback-Registrierung gebunden sind, Handles rund um den Erfassungs-Thread |
Bei Steuerungsanwendungen wird besonders häufig folgendes Muster zum Problem: eine Ressource, die für einen bestimmten Vorgang vorübergehend geöffnet wurde, wird auf einem Teilfehlerpfad nicht mehr geschlossen.
Typischerweise sieht der Ablauf so aus.
- Bei jedem Reconnect wird ein event erzeugt
- Die callback-Registrierung oder der Start der Bildaufnahme schlägt unterwegs fehl
- Auf dem success path wird geschlossen, auf dem failure path aber nicht
- Kurze Alltagstests durchlaufen meist nur den Erfolgspfad, sodass es übersehen wird
Dieser Typ schlüpft ziemlich routinemäßig durch, sowohl bei Code-Reviews als auch im Produktivbetrieb.
2.2. Warum es erst bei Langzeitbetrieb auffällt
Ein Handle-Leak muss nicht auf einen Schlag spektakulär alles zum Absturz bringen. Lästiger ist eher ein Leak mit kleiner Steigung, bei dem ein einzelner Fehler nur ein einziges Handle verliert.
flowchart LR
A[Normalbetrieb] --> B[Gelegentlich timeout / reconnect]
B --> C[Failure path erzeugt ein Event Handle]
C --> D[CloseHandle wird nicht aufgerufen]
D --> E[Handle Count steigt leicht an]
E --> F[Wiederholt sich hunderte Male]
F --> G[CreateEvent / SDK open schlägt fehl]
G --> H[Absturz / Stillstand an anderer Stelle]
Wenn pro Reconnect nur ein einziges Handle verloren geht, passiert in wenigen Minuten nichts. Bei einer 24/7 laufenden Gerätesteuerungsanwendung treten Randbedingungen wie timeout, Reinitialisierung oder Wiederherstellung nach einer Trennung jedoch immer wieder auf. Das Ergebnis ist das merkwürdige Bild, dass sich das Problem erst nach mehreren Wochen zeigt.
Wichtig ist dabei, dass das Handle-Leak selbst nicht zwangsläufig die abstürzende Zeile ist. Häufig sieht der Zusammenbruch so aus.
- Eine API, die ein neues event / file / thread erzeugen soll, schlägt fehl
- Das SDK kann intern eine benötigte Ressource nicht anlegen und liefert nur einen generischen Fehlercode zurück
- Die Fehlerbehandlung danach ist dünn, es wird ein
null- bzw. invalid handle angefasst, und die Anwendung stürzt ab - Die Anzahl der timeouts steigt, und am Ende wird der Prozess von einem watchdog oder einer übergeordneten Steuerung beendet
Mit anderen Worten: Die Absturzstelle ist das „letzte Opfer“, nicht zwangsläufig der „ursprüngliche Täter“.
Hier stellt sich eine naheliegende Frage: Warum stürzt die Anwendung schon bei gerade einmal ein paar tausend Handles ab?
Betrachtet man nur die Zahl, liegt die Obergrenze ziemlich weit weg. Für Kernelobjekt-Handles gilt pro Prozess theoretisch eine Obergrenze von 2^24 (rund 16,77 Millionen). Handles liegen jedoch im Page Pool, sodass die tatsächlich erzeugbare Anzahl vom verfügbaren Speicher abhängt, und unter 32-Bit-Windows liegt sie deutlich unter diesem theoretischen Wert.
Kurz gesagt: Fälle, in denen tatsächlich die theoretische Obergrenze erreicht wird, sind eher die Minderheit. Was in der Praxis zuerst zuschlägt, ist meist eines der folgenden.
| Was zuerst an eine Grenze stößt | Richtwert | Wo es zuschlägt |
|---|---|---|
| GDI-Objekte | Theoretisch 65.536 pro Sitzung. Zusätzlich gibt es ein Standardlimit pro Prozess, das sich über die Registry unter GDIProcessHandleQuota im Bereich 256-65.536 ändern lässt |
Anwendungen mit einer GUI im selben Prozess. Hier wird die Grenze schon im Bereich weniger tausend Objekte ganz normal erreicht |
| Interne Verwaltungstabelle des SDK | Herstellerabhängig | Die Handle-Tabelle oder das Array fester Länge, die das Kamera-SDK intern führt, laufen zuerst voll |
| Kernelressourcen wie der Page Pool | Systemweit gemeinsam genutzt | Wenn neben Handles auch andere Ressourcen aufgebraucht werden |
| Virtueller Adressraum eines 32-Bit-Prozesses | 2 GB / 3 GB | Hier schlägt weniger das Handle selbst zu, sondern der damit verbundene Puffer |
Die Lesart „bis zur Obergrenze ist noch Luft, also ist alles in Ordnung“ trägt also nicht. Man sollte nicht daran messen, ob die Obergrenze erreicht wird, sondern daran, ob etwas, das eigentlich zurückgehen sollte, tatsächlich zurückgeht. Sobald sich eine Steigung zeigt, ist es sicherer, das bereits als Anomalie zu behandeln.
2.3. Der Unterschied zum Memory-Leak
Bei Defekten nach langem Dauerbetrieb liegt der erste Verdacht meist bei einem Memory-Leak. Das ist an sich natürlich, aber ein Handle-Leak lässt sich manchmal schneller finden, wenn man entlang einer anderen Achse schaut.
| Aspekt | Memory-Leak | Handle-Leak |
|---|---|---|
| Zuerst zu beobachtende Kennzahl | Private Bytes, Commit, Working Set |
Handle Count |
| Typische Symptome | Speicherdruck, Paging, Verlangsamung, OOM | Fehlschläge von Create* / Open* / interner SDK-Initialisierung, Folgeschäden |
| Häufiges Versteck | Caches, gehaltene Referenzen, vergessene Freigaben | Asymmetrie zwischen create/open und close/dispose |
| Erscheinungsbild | Speicher steigt allmählich an | handle count steigt allmählich an und geht nicht zurück |
Bei der Eingrenzung von Langzeitbetriebsproblemen führt „nur den Speicher zu beobachten“ deshalb leicht dazu, dass man praktisch mit einem Auge fährt.
Mindestens Handle Count und Thread Count gemeinsam zu beobachten macht die Sache erheblich übersichtlicher.
3. Fallbeispiel: Eine Industriekamera-Steuerungsanwendung stürzt nach einem Monat plötzlich ab
3.1. Die aufgetretenen Symptome
Der Vorfall war einfach.
- Eine Windows-Anwendung zur Steuerung einer Industriekamera läuft 24/7
- Im Normalbetrieb funktioniert sie einwandfrei
- Nach etwa einem Monat stürzt die Anwendung eines Tages plötzlich ab
- Nach einem Neustart läuft sie wieder eine Weile problemlos
Das erste Problem ist, dass es lange dauert, bis es abstürzt. Bei jeder Reproduktion einen Monat zu warten, ist für eine Untersuchung ziemlich hart.
Noch lästiger war, dass die Absturzstelle nicht jedes Mal exakt dieselbe war. Mal war es unmittelbar nach Beginn eines Reconnects, mal beim Start der Bildaufnahme, mal nach einem fehlgeschlagenen SDK-Aufruf.
Bei diesem Bild kommen zunächst alle folgenden Verdächtigen infrage.
- Instabilität auf Seiten des Kamera-SDK
- Vorübergehende Störungen durch Kommunikation oder Geräteabtrennung
- Ein Memory-Leak
- Eine Race Condition rund um Threads
- Ein Initialisierungsfehler, der nicht im Log auftaucht
Kurz gesagt: Es gab zu viele „irgendwie verdächtige“ Kandidaten.
3.2. Die zuerst betrachteten Kennzahlen
Deshalb haben wir zunächst betrachtet, wie die Ressourcen des Prozesses insgesamt anwuchsen. In diesem Fall zeigten die Beobachtungen ungefähr folgende Tendenz.
| Kennzahl | Beobachtete Tendenz | Deutung |
|---|---|---|
Handle Count |
Steigt nach reconnect bzw. timeout jeweils leicht an und geht nicht zurück | Handle-Leak vermuten |
Private Bytes |
Schwankt, aber die Steigung eines monotonen Anstiegs ist schwach | Der Übeltäter ist nicht zwangsläufig der heap |
Thread Count |
Nahezu konstant | Ein thread leak ist unwahrscheinlich |
| Absturzstelle | Jedes Mal etwas anders | Ein Folgeschaden ist wahrscheinlich |
Zu diesem Zeitpunkt hatte sich der Blick schon deutlich verengt. Es war naheliegender zu sagen: nicht „es stürzt nach einem Monat ab“, sondern „unterwegs wird stetig etwas Kleines verloren, und als Folge davon stürzt es nach einem Monat ab“.
3.3. Die Stelle, die die eigentliche Ursache war
Die letztendliche Ursache war ein vergessenes Schließen des event handle, das auf dem Initialisierungs-Fehlerpfad bei einem Kamera-Reconnect erzeugt wurde.
Vereinfacht sieht der Ablauf so aus.
sequenceDiagram
participant App as Steuerungsanwendung
participant OS as Windows
participant SDK as Kamera-SDK
App->>OS: CreateEvent
App->>SDK: callback registrieren
SDK-->>App: Teilfehler / timeout
Note over App: return auf dem failure path
Note over App: CloseHandle wird nicht aufgerufen
loop Mehrfacher reconnect
App->>OS: Handle Count steigt leicht an
end
App->>OS: Nächstes CreateEvent / Open
OS-->>App: Fehlschlag
App-->>App: Absturz als Folgeschaden
Als Code-Skizze sieht das Leck so aus.
handle = CreateEvent(...)
if (!RegisterCallback(handle))
{
return Error; // CloseHandle(handle) fehlt hier
}
if (!StartAcquisition())
{
return Error; // auch hier fehlt das close
}
...
CloseHandle(handle)
Auch warum das bei kurzen Tests leicht übersehen wird, ist ziemlich klar.
- Bei normalem Start -> normalem Beenden wird close aufgerufen
- Fehlschläge treten nur mitten im reconnect auf
- Es gibt keinen Test, der diesen failure path massenhaft durchläuft
- In Produktion summiert es sich über Wochen langsam auf
Kurz gesagt: Die Struktur war „unsichtbar, solange man nur den Normalpfad betrachtet, aber auf dem Fehlerpfad leckt es ganz gewöhnlich“.
Die Korrekturrichtung ist nicht spektakulär.
- Die Verantwortlichkeiten von
create/openundclose/disposenäher zusammenrücken - Die Freigabe zu
finally/ destructor / einem session object hin verlagern, damit sie auch bei einem Teilfehler garantiert erfolgt - Vor und nach callback-Registrierung und Start der Bildaufnahme das ownership klar festlegen
- „Wer schließt“ nicht per comments, sondern durch die Verantwortlichkeit im Code ausdrücken
Da das in reiner Textform schwer nachvollziehbar ist, zeigen wir zusätzlich, wie sich derselbe Vorgang umschreiben lässt.
Unter C++ legt man einen kleinen RAII-Typ an, der das Handle hält, damit kein rohes HANDLE innerhalb einer Funktion herumliegt.
// C++17 / Windows
#include <windows.h>
#include <utility>
class UniqueHandle
{
public:
UniqueHandle() noexcept = default;
explicit UniqueHandle(HANDLE h) noexcept : h_(h) {}
UniqueHandle(const UniqueHandle&) = delete;
UniqueHandle& operator=(const UniqueHandle&) = delete;
UniqueHandle(UniqueHandle&& other) noexcept
: h_(std::exchange(other.h_, nullptr)) {}
UniqueHandle& operator=(UniqueHandle&& other) noexcept
{
if (this != &other)
{
reset(std::exchange(other.h_, nullptr));
}
return *this;
}
~UniqueHandle() { reset(); }
HANDLE get() const noexcept { return h_; }
explicit operator bool() const noexcept { return h_ != nullptr; }
void reset(HANDLE h = nullptr) noexcept
{
if (h_ != nullptr)
{
::CloseHandle(h_);
}
h_ = h;
}
private:
HANDLE h_ = nullptr;
};
Verwendet man diesen Typ, muss man CloseHandle nicht mehr eigens in den Fehlerpfad einfügen.
// Member von CameraSession: UniqueHandle frameReady_;
bool CameraSession::Reconnect()
{
UniqueHandle frameReady{ ::CreateEventW(nullptr, TRUE, FALSE, nullptr) };
if (!frameReady)
{
return false; // Erzeugung selbst ist fehlgeschlagen. Es gibt nichts zu schließen
}
if (!RegisterCallback(frameReady.get()))
{
return false; // Auch bei diesem return schließt der Destruktor
}
if (!StartAcquisition())
{
// Bei einem Fehlschlag nach abgeschlossener Registrierung zuerst die
// Registrierung entfernen, bevor geschlossen wird.
// Verlässt man die Funktion ohne das Entfernen, ruft der Destruktor zwar
// CloseHandle auf, aber das SDK behält weiterhin das übergebene Handle.
// Beim nächsten Frame würde dann eine bereits freigegebene Nummer signalisiert,
// und falls diese Nummer inzwischen für eine andere Ressource wiederverwendet
// wurde, äußert sich das dadurch, dass "ein unbeteiligtes Event sich von selbst setzt"
UnregisterCallback();
return false;
}
// Nur bei Erfolg das ownership an die session übertragen
frameReady_ = std::move(frameReady);
return true;
}
Unter C# lässt sich die Sache oft nicht mit einem einzigen using erledigen, deshalb bildet man „ob das ownership übergeben werden konnte“ als Flag ab und wirft die Ressource nur dann in finally weg, wenn das nicht gelungen ist. Schreibt man einfach using var, wird die Ressource nämlich auch dann verworfen, wenn der Vorgang erfolgreich war.
// C# / .NET 8
// Feld von CameraSession: private ManualResetEvent? _frameReady;
public bool Reconnect()
{
var frameReady = new ManualResetEvent(false);
var handedOver = false;
var registered = false;
try
{
if (!RegisterCallback(frameReady))
{
return false;
}
registered = true;
if (!StartAcquisition())
{
return false;
}
_frameReady?.Dispose();
_frameReady = frameReady;
handedOver = true;
return true;
}
finally
{
if (!handedOver)
{
// Vor dem Verwerfen zuerst die extern gehaltene Referenz entfernen.
// Das SDK behält das bei der Registrierung übergebene Handle, daher
// würde eine umgekehrte Reihenfolge ein bereits freigegebenes Handle ansprechen
if (registered)
{
UnregisterCallback();
}
frameReady.Dispose();
}
}
}
In beiden Fällen passiert im Kern dasselbe. Die Struktur stellt sicher, dass eine Ressource ohne festgelegten Besitzer garantiert verworfen wird, egal an welcher Stelle die Funktion verlassen wird. Statt dass ein Mensch jedes Mal „bei Fehlschlag schließen“ schreibt, übernehmen das der Typ und finally.
Das ist weniger eine besondere Technik als vielmehr eine saubere Struktur, die die Lebensdauer der Ressource fest im Code verankert.
4. Wie wir es eingegrenzt haben
Ab diesem Kapitel tauchen englische Fachbegriffe aus der Untersuchung unverändert auf. Vorab eine kurze Begriffsübersicht.
| Begriff | Deutsch etwa | Bedeutung in diesem Artikel |
|---|---|---|
| baseline | Referenzwert | Der Wert, der sich nach Abschluss des Warmlaufs eingependelt hat. Ab hier wird die Differenz betrachtet |
| leakSlope | Leck-Steigung | Wie viele Handles pro Zyklus dazukommen. Eine selbst definierte Kennzahl für die Geschwindigkeit des Anstiegs |
| structured log | strukturiertes Log | Ein Log, das nicht aus Fließtext besteht, sondern feste Felder im Format key=value ausgibt. Lässt sich später maschinell auswerten |
| heartbeat | regelmäßige Meldung | Ein Log, das in festen Abständen fortlaufend einen Lebenszeichen-Check und Ressourcenwerte ausgibt |
| harness | Testgerüst | Ein kleines Ausführungsprogramm, das anstelle der Hauptanwendung nur den zu testenden Ablauf wiederholt ausführt |
| phase | Phase | Eine Markierung wie OpenStart oder ReconnectStart, die zeigt, in welchem Abschnitt der Verarbeitung man sich gerade befindet |
4.1. Zeit sparen, statt auf eine monatelange Reproduktion zu warten
Bei einer solchen Untersuchung ist es ein schlechter Ansatz, jedes Mal einen Monat abzuwarten. Was man tun sollte, ist, den verdächtigen Pfad in kurzer Zeit immer wieder zu durchlaufen.
In diesem Fall haben wir die Reproduktion komprimiert, indem wir folgende Schleife durchlaufen ließen.
flowchart LR
A[Start] --> B[Kamera open]
B --> C[Bildaufnahme starten]
C --> D[Simulierter timeout / Verbindungsabbruch]
D --> E[Reconnect]
E --> F[Bildaufnahme fortsetzen]
F --> G{N-mal wiederholen}
G -- Ja --> D
G -- Nein --> H[Differenz am Ende prüfen]
Der entscheidende Punkt ist, die Zeit nicht für die normale „es wird aufgenommen“-Phase zu verwenden, sondern für die Lebenszyklus-Operationen an den Grenzen.
Konkret wirksam sind Szenarien wie diese.
open -> start -> stop -> closein großer Zahl durchlaufen- Absichtlich timeouts auslösen und reconnect durchspielen
- Direkt nach der callback-Registrierung einen Fehlschlag erzwingen
- Abbrüche bei Trennung und Reconnect sowie shutdown-Races einbauen
Es ist nicht nötig, einen Monat realen Betrieb perfekt nachzubilden. Im Gegenteil: den verdächtigen lifetime edge mehrere tausend Mal zu treffen, bringt einen der Ursache erheblich näher.
4.2. Die Steigung von Handle Count betrachten
Vorab halten wir fest, wo man Handle Count überhaupt beobachten kann. Ohne das ist der ganze restliche Abschnitt nur graue Theorie.
| Mittel | Vorgehen | Geeignet für |
|---|---|---|
| Task-Manager | Den Tab „Details“ öffnen, mit Rechtsklick auf die Spaltenüberschriften → „Spalten auswählen“ → das Häkchen bei „Handles“ setzen | Aktuelle Anzahl sofort sehen wollen |
| Process Explorer | Den Prozess auswählen, die Eigenschaften öffnen und im Tab Process Performance den Handle Count betrachten. Sortiert man die Handles-Ansicht im unteren Bereich nach Type, erhält man auch eine Aufschlüsselung nach Art | Wissen wollen, welche Art von Handle zunimmt |
handle.exe |
Mit handle -s -p CameraApp eine textuelle Zusammenfassung nach Typ abrufen |
Momentaufnahmen im Log festhalten wollen |
| PowerShell | Get-Process -Name CameraApp \| Select-Object Name, Id, HandleCount |
Regelmäßig per Skript abfragen wollen |
typeperf |
typeperf "\Process(CameraApp)\Handle Count" -si 60 -sc 1440 -o handles.csv |
Über einen langen Zeitraum direkt als CSV mitschreiben wollen |
| Die Anwendung selbst | GetProcessHandleCount bzw. Process.HandleCount in das heartbeat-Log einbetten |
Auf dem Produktivsystem nur Logs einsammeln wollen |
Wie man die Aufschlüsselung nach Art und das Wachstum namenloser events nachverfolgt, ist als Vorgehen im Artikel „Process Explorer / Handle / VMMap in der Praxis“ zusammengefasst.
Bei der Untersuchung von Langzeitbetrieb ist die vielversprechendste Option die unterste: „Die Anwendung gibt es selbst aus.“ Dass ein Mensch rund um die Uhr den Task-Manager beobachtet, lässt sich im 24/7-Betrieb nicht durchhalten.
Bei der Untersuchung eines Handle-Leaks ist es manchmal schwer zu beurteilen, wenn man nur den absoluten Wert betrachtet. Wichtig sind ob der Wert nach einem Vorgang, bei dem er zurückgehen sollte, tatsächlich zurückgeht, und wie viele Handles bei wie vielen Vorgängen dazukommen.
Als Vorgehen ist ungefähr diese Reihenfolge nachvollziehbar.
- Nach dem Warmlauf die baseline festlegen
- Nach reconnect / start-stop / close jeweils
Handle Countprotokollieren - Die Differenz pro Zyklus betrachten
- Auch die über mehrere Zyklen zusammengefasste Steigung betrachten
Zum Beispiel so.
leakSlope =
(currentHandleCount - baselineHandleCount)
/ reconnectCount
Ob ein absoluter Wert von 2000 viel oder wenig ist, hängt stark von der Anwendung ab. Aber wenn es pro reconnect +1 ist und nicht zurückgeht, ist das ziemlich verdächtig.
Als Anhaltspunkt, wie ein gesunder Normalfall aussehen sollte: Da die konkreten Zahlen von der Anwendung abhängen, beurteilt man das an der Form.
- Direkt nach dem Start steigt der Wert. Das wird nicht mitgelesen
- Nach Abschluss des Warmlaufs sollte der Wert je nach Vorgang schwanken, aber innerhalb eines festen Bereichs hin- und herpendeln
- Nach einem Zyklus
open -> start -> stop -> closeist es normal, wenn der Wert wieder fast auf den Stand vor dem Zyklus zurückgeht - Liegt die Differenz zur baseline nach 100 Zyklen innerhalb weniger Einheiten, ist das erst einmal unauffällig
- Steigt der Wert dagegen proportional zur Zyklenzahl sauber linear an, geht bei jedem Zyklus genau diese Steigung als Leck verloren
Man beurteilt nicht „viel oder wenig“, sondern ob der Wert zurückgeht oder nicht. Verwechselt man das, verdächtigt man eine gesunde Anwendung und verliert dabei Zeit.
Der Trick dabei ist, Handle Count nicht isoliert zu betrachten, sondern mindestens Folgendes gemeinsam zu protokollieren.
Handle CountPrivate BytesThread CountReconnectCount- Die aktuelle phase
Damit lässt sich ziemlich schnell erkennen, ob „der Speicher wächst“, ob „die Threads zunehmen“ oder ob „die Ressourcen bei jedem Reconnect nicht zurückgehen“.
4.3. Die Zuordnung von create/open und close/dispose prüfen
Selbst wenn feststeht, dass der prozessweite Handle Count verdächtig ist, kommt man damit allein nicht bis zur Leckstelle.
Als Nächstes braucht man Logs, die den Lebenszyklus der Ressource paarweise abbilden.
Als Vorstellung etwa ein structured log wie dieses.
CameraSession session=421 cameraId=CAM01 phase=ReconnectStart reason=FrameTimeout handleCount=1824 privateBytesMB=418
CameraResource session=421 resourceId=evt-884 kind=Event name=FrameReady action=Create osHandle=0x00000ABC handleCount=1825
CameraResource session=421 resourceId=evt-884 kind=Event name=FrameReady action=Close osHandle=0x00000ABC handleCount=1824
Wichtig dabei ist, sich nicht allein auf osHandle zu verlassen.
Da Windows-Handle-Werte später wiederverwendet werden können, lässt sich im Log leichter nachverfolgen, wenn man mindestens Folgendes mitführt.
sessionIdresourceIdkindaction(Create/Open/Register/Close/Dispose/Unregister)osHandlephase
So lässt sich der einseitige Ablauf, bei dem ein Create existiert, aber kein Close, leichter aufspüren.
4.4. Beim Handle-Leak nicht die Absturzstelle, sondern die Leckstelle suchen
Dieser Punkt ist ziemlich wichtig.
Ein Handle-Leak zeigt sich oft in dieser Form.
- Absturzzeile:
CreateEventschlägt fehl - Tatsächliches Leck: Seit Tagen fehlte auf dem failure path
CloseHandle
Mit anderen Worten: Die API, die zuletzt fehlschlug, ist der Ausgang des Schadens, nicht zwangsläufig der Eingang der Ursache.
Die Reihenfolge der Untersuchung sollte deshalb sein:
- Betrachten, welche Ressource stetig wächst
- Betrachten, an welcher Vorgangsgrenze sie nicht zurückgeht
- Die Stelle suchen, an der die Paarung von
create/openundclose/disposegebrochen ist - Die Absturzstelle erst zum Schluss lesen
In dieser Reihenfolge verirrt man sich deutlich seltener.
5. Die Logs, die zur Vorbeugung nötig sind
5.1. Das Minimum, das zuerst erfasst werden sollte
Was in dieser Untersuchung geholfen hat, war nicht einfach mehr Log-Volumen. Es war, „Informationen, mit denen man später zur Ursache gelangt“ gezielt und strukturiert zu ergänzen.
Mindestens die folgenden Punkte sollte man festhalten.
| Kategorie | Mindestens gewünschte Felder | Grund |
|---|---|---|
| Vorgangskontext | cameraId, sessionId, operationId, reconnectCount, phase |
Um zuordnen zu können, bei welchem Vorgang und beim wievielten Durchlauf es passiert ist |
| Prozessressourcen | handleCount, privateBytes, workingSet, threadCount |
Um zuerst einzugrenzen, was wächst |
| resource lifecycle | action, resourceId, kind, osHandle, owner |
Um das Paar aus create/open und close/dispose zu verfolgen |
| Ergebnisse externer Aufrufe | win32Error, HRESULT, sdkError, timeoutMs |
Um Fehlerarten später vergleichen zu können |
| Zustandsübergänge | OpenStart, OpenDone, ReconnectStart, ReconnectDone, ShutdownStart usw. |
Um zu wissen, mitten in welcher phase es zusammengebrochen ist |
| Ausführungsumgebung | pid, tid, buildVersion, machineName |
Um den Bezug zu dump / symbol / ausgelieferten Artefakten herzustellen |
Wir behaupten nicht, dass das ausreicht. Aber ohne mindestens das bleibt leicht nur ein Log übrig, das allein die Tatsache festhält, dass „es abgestürzt ist“.
5.2. Die tatsächlich verstärkten Logs
In diesem Fall haben wir die Logs in folgende Richtungen verstärkt.
- Regelmäßiger heartbeat
- Alle 1-5 Minuten
Handle Count/Private Bytes/Thread Count/ReconnectCountausgeben
- Alle 1-5 Minuten
- Grenzlogs pro Kamera-session
OpenStartCallbackRegisteredAcquisitionStartTimeoutDetectedReconnectStartReconnectDoneCloseStartCloseDone
- Logs zum Ressourcen-Lebenszyklus
Create/Open/RegisterundClose/Dispose/Unregisterfür event / thread / file / timer / SDK registration token
- Normalisierung der Fehler
- Sich nicht mit der exception message begnügen, sondern gleichzeitig
win32Error,HRESULT,sdkErrorundphaseausgeben
- Sich nicht mit der exception message begnügen, sondern gleichzeitig
Wichtig ist, das Format des Logs bei Erfolg und bei Fehlschlag nicht zu ändern. Wenn nur der Fehlerfall ein anderes Format bekommt, wird die spätere Auswertung mühsam.
5.3. In welcher Granularität erfasst werden sollte
Hier passiert leicht der Fehler, „erst mal alles auf INFO auszugeben“. Macht man das, entsteht beim späteren Lesen eine Wand aus Logs. Das ist ziemlich mühsam.
Was die Granularität angeht, ist ungefähr folgende Aufteilung realistisch.
- Regelmäßige Überwachung
Handle Count,Private Bytes,Thread Count,ReconnectCount
- Vorgangsgrenzen
- start / done / fail der session
- Ressourcengrenzen
create/open/registerundclose/dispose/unregister
- Details im Fehlerfall
- error code, stack, Auslöser für die dump-Erfassung
Ein detailliertes Log für jeden einzelnen Frame ist normalerweise nicht nötig. Effektiver bei Langzeitfehlern ist vielmehr ein Log, aus dem sich ablesen lässt, „welche Verantwortlichkeit geöffnet und welche geschlossen hat“.
6. Grobe Einsatzunterscheidung
- Stürzt erst nach Tagen bis Wochen ab
- Zuerst einen heartbeat für
Handle Count/Private Bytes/Thread Counteinbauen
- Zuerst einen heartbeat für
- Es gibt retry / reconnect / shutdown
- Zuerst ein harness bauen, das genau diese Grenzen massenhaft durchläuft
- Verwendet viel native SDK / P/Invoke / Win32
- Der im zweiten Teil beschriebene Application Verifier lohnt sich hier besonders
- Es läuft zusätzlich eine GUI im selben Prozess
- Neben
Handle Countsollte man auchGDI Objects/USER Objectsbeobachten
- Neben
- Die exception im Moment des Absturzes allein sagt gar nichts
- Es ist schneller, zuerst structured logs für operation / session / resource lifecycle einzurichten
Der letzte Punkt ist ziemlich wichtig. Bei der Fehleranalyse entscheidet oft weniger die Analysetechnik selbst über den Erfolg als vielmehr, ob die Dinge in beobachtbarer Form vorliegen.
7. Zusammenfassung
Bei einer Anwendung, die erst nach langem Dauerbetrieb abstürzt, nicht nur den Speicher, sondern auch Handle Count beobachten. Ein Handle-Leak versteckt sich eher im failure path des Fehlerfalls als im Normalpfad, und die Absturzstelle ist meist nicht die Leckstelle, sondern der Ausgang eines Folgeschadens. Was das Lesen der Symptome angeht, läuft am Ende alles auf diese drei Punkte hinaus.
Zur Vorbeugung: die Verantwortlichkeiten von create/open und close/dispose näher zusammenrücken, Logs mit Kontext pro session / operation führen und sowohl Prozessressourcen als auch resource lifecycle protokollieren. Beim Testen nicht auf eine monatelange Reproduktion warten, sondern timeout / reconnect / shutdown in kurzen Schleifen durchspielen und nicht nur „bricht nicht zusammen“, sondern „lässt sich nachvollziehen, wenn es zusammenbricht“ als Abnahmekriterium ansetzen. Genau diese Kombination hat in diesem Fall funktioniert. Im zweiten Teil bringen wir mit dem Application Verifier schwer auslösbare Fehlerarten wie Speichermangel oder Handle-Anomalien vorzeitig ans Licht.
Bei Steuerungsanwendungen zählt zwar, dass der Normalpfad funktioniert, aber im Langzeitbetrieb zahlt sich vor allem aus, wenn man beim Zusammenbruch nachvollziehen kann, „was passiert ist“.
Ein Handle-Leak ist genau der Fehlertyp, bei dem sich dieser Unterschied auszahlt. Wer nicht nur den Moment des Auftretens betrachtet, sondern das Wachstum, die Grenzen und die Verantwortlichkeitspaare, kommt der Sache erheblich leichter auf die Spur.
Zweiter Teil: Application Verifier: Windows-Testfundament für Fehlerpfade
8. Quellen
- GetProcessHandleCount-Funktion (processthreadsapi.h)
- Process.HandleCount-Eigenschaft (System.Diagnostics)
- Kernel Objects - Win32 apps
- GDI Objects - Win32 apps
- typeperf - Windows Commands
- Process Explorer / Handle / VMMap in der Praxis — Hängern, Lecks und „Datei wird verwendet“ vom aktuellen Zustand aus nachjagen
- Zweiter Teil: Application Verifier: Windows-Testfundament für Fehlerpfade
Verwandte Artikel
Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.
Ein Testfundament für Windows-Failure-Paths mit Application Verifier
Was Application Verifier ist, erklärt zusammen mit dem Aufbau eines Windows-Testfundaments für Failure-Path-Tests mit Handles, Heaps, Low...
TCP-Retransmission als Ursache für Kommunikationsausfälle bei Industriekameras – Ursachensuche und Eingrenzung
Wie Sie eingrenzen, wenn die Kommunikation mit einer Industriekamera durch TCP-Retransmission für einige Sekunden stoppt: mit Paketverlus...
Energiesparmodus, Ruhezustand, Modern Standby und lang laufende Anwendungen ── Design gegen „über Nacht stehengeblieben“
Warum eine lang laufende Windows-Anwendung morgens beim Nachsehen „stehengeblieben“ sein kann, aufgearbeitet anhand der Unterschiede zwis...
Wenn Sie ein System ohne Quellcode und ohne Dokumentation übernehmen — Ein praktisches Playbook, um es am Laufen zu halten
Ein praktisches Playbook für den Beginn von Betrieb und Wartung eines Geschäftssystems ohne Quellcode und ohne Spezifikationen. Behandelt...
MAX_PATH und die Fallstricke von Windows-Pfaden und Dateinamen ── Das 260-Zeichen-Limit, reservierte Namen, abschließende Punkte und Groß-/Kleinschreibung
Ein Überblick über die Pfad- und Dateinamensbeschränkungen hinter dem klassischen Fehler „Datei nicht gefunden“. Behandelt werden die Zus...
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.
Fehleranalyse und Langzeitprobleme
Sporadische Fehler, Kommunikationsdiagnose, Langzeitabstürze und Tests von Fehlerpfaden.
Verwandte Fallstudien
Diese Fallstudien zeigen einen ähnlichen Ansatz für Analyse, Priorisierung oder Neugestaltung.
Wie wir einen Absturz nach langer Laufzeit auf ein Handle-Leck zurückgeführt haben
Fallstudie dazu, wie bessere Beobachtungspunkte und Protokollierung einen nur monatlich auftretenden Absturz in eine gezielte Untersuchung eines Handle-Lecks verwandelten.
Leistungen zu diesem Thema
Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.
Fehleruntersuchung und Ursachenanalyse
Die Eingrenzung von Störungen, die erst nach langem Dauerbetrieb auftreten, passt hervorragend zu unserer Beratung für Fehleranalyse und Ursachenermittlung.
Windows-App-Entwicklung
Wenn Sie die Architektur Ihrer Windows-Anwendung einschließlich Log-Design und Betriebsüberwachung überarbeiten möchten, führt das auch zu einer Beratung im Bereich Windows-Anwendungsentwicklung.
Häufige Fragen
Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.
- Was ist ein Handle-Leak?
- Ein Handle-Leak liegt vor, wenn ein Windows-Prozess vergisst, ein Handle zu schließen, mit dem er auf OS-Ressourcen wie event, mutex, file oder socket verweist, sodass der Handle Count immer weiter ansteigt. Besonders häufig ist das Muster, dass eine Ressource, die für einen bestimmten Vorgang vorübergehend geöffnet wurde, auf einem Fehlerpfad wie timeout, reconnect oder early return nicht geschlossen wird - und weil kurze Alltagstests fast nur den Erfolgspfad durchlaufen, fällt das leicht unter den Tisch.
- Wie unterscheidet man einen Memory-Leak von einem Handle-Leak?
- Die entscheidenden Kennzahlen sind unterschiedlich. Bei einem Memory-Leak steigen Private Bytes bzw. Commit allmählich an, bei einem Handle-Leak steigt stattdessen der Handle Count allmählich an und geht nicht mehr zurück. Wer bei der Eingrenzung von Langzeitbetriebsproblemen nur auf den Speicher schaut, fährt praktisch mit einem Auge zu - deshalb gehört es zum Grundvorgehen, Handle Count und Thread Count gemeinsam zu beobachten. Läuft zusätzlich eine GUI im selben Prozess, sollte man auch GDI Objects und USER Objects im Blick behalten.
- Warum stürzt eine Anwendung mit einem Handle-Leak erst nach langem Dauerbetrieb ab?
- Ein Leak mit kleiner Steigung - bei dem ein einzelner Fehler nur ein einziges Handle verliert - richtet in wenigen Minuten noch nichts an. Bei einem 24/7-Betrieb treten Randbedingungen wie timeout oder Reconnect jedoch immer wieder auf und summieren sich über Wochen. Irgendwann schlägt eine API fehl, die ein neues event/file/thread erzeugen will, und der Fehler tritt als Folgeschaden zutage. Wichtig dabei: Die Absturzstelle ist meist nicht der Ort des Lecks, sondern nur das letzte Opfer.
- Wie untersucht man am besten einen Handle-Leak?
- Statt auf eine monatelange Reproduktion zu warten, komprimiert man die Zeit, indem man verdächtige Lebenszyklus-Grenzen wie open -> start -> stop -> close oder timeout/reconnect in einer kurzen Schleife mehrere tausend Mal durchläuft. Man legt nach dem Warmlauf eine baseline fest, betrachtet Differenz und Steigung des Handle Count pro Zyklus, sucht mit einem structured log, das sessionId, resourceId und action enthält, nach Stellen, an denen create/open und close/dispose nicht mehr zusammenpassen, und liest die Absturzstelle erst ganz zum Schluss - in dieser Reihenfolge verirrt man sich am wenigsten.
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.