Untersuchung eines Absturzes einer Industriekamera-Anwendung nach Langzeitbetrieb - Der Handle-Leak (Teil 1)

· · 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

  1. Zuerst das Fazit (in Kürze)
  2. 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
  3. 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
  4. Wie wir es eingegrenzt haben
    • 4.1. Zeit sparen, statt auf eine monatelange Reproduktion zu warten
    • 4.2. Die Steigung von Handle Count betrachten
    • 4.3. Die Zuordnung von create/open und close/dispose prüfen
    • 4.4. Beim Handle-Leak nicht die Absturzstelle, sondern die Leckstelle suchen
  5. 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
  6. Grobe Einsatzunterscheidung
  7. Zusammenfassung
  8. Quellen

1. Zuerst das Fazit (in Kürze)

  • Bei einer Steuerungsanwendung, die erst nach langem Dauerbetrieb abstürzt, unbedingt nicht nur Private Bytes, sondern auch Handle Count beobachten
  • 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, der handle count des Prozesses, die open/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.

NormalbetriebGelegentlich timeout / reconnectFailure path erzeugt ein Event HandleCloseHandle wird nicht aufgerufenHandle Count steigt leicht anWiederholt sich hunderte MaleCreateEvent / SDK open schlägt fehlAbsturz / 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.

Kamera-SDKWindowsSteuerungsanwendungKamera-SDKWindowsSteuerungsanwendungreturn auf dem failure pathCloseHandle wird nicht aufgerufenloop[Mehrfacher reconnect]CreateEventcallback registrierenTeilfehler / timeoutHandle Count steigt leicht anNächstes CreateEvent / OpenFehlschlagAbsturz 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/open und close/dispose nä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.

JaNeinStartKamera openBildaufnahme startenSimulierter timeout / VerbindungsabbruchReconnectBildaufnahme fortsetzenN-mal wiederholenDifferenz 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 -> close in 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.

  1. Nach dem Warmlauf die baseline festlegen
  2. Nach reconnect / start-stop / close jeweils Handle Count protokollieren
  3. Die Differenz pro Zyklus betrachten
  4. 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 -> close ist 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 Count
  • Private Bytes
  • Thread Count
  • ReconnectCount
  • 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.

  • sessionId
  • resourceId
  • kind
  • action(Create/Open/Register/Close/Dispose/Unregister)
  • osHandle
  • phase

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: CreateEvent schlä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:

  1. Betrachten, welche Ressource stetig wächst
  2. Betrachten, an welcher Vorgangsgrenze sie nicht zurückgeht
  3. Die Stelle suchen, an der die Paarung von create/open und close/dispose gebrochen ist
  4. 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.

  1. Regelmäßiger heartbeat
    • Alle 1-5 Minuten Handle Count / Private Bytes / Thread Count / ReconnectCount ausgeben
  2. Grenzlogs pro Kamera-session
    • OpenStart
    • CallbackRegistered
    • AcquisitionStart
    • TimeoutDetected
    • ReconnectStart
    • ReconnectDone
    • CloseStart
    • CloseDone
  3. Logs zum Ressourcen-Lebenszyklus
    • Create/Open/Register und Close/Dispose/Unregister für event / thread / file / timer / SDK registration token
  4. Normalisierung der Fehler
    • Sich nicht mit der exception message begnügen, sondern gleichzeitig win32Error, HRESULT, sdkError und phase ausgeben

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/register und close/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 Count einbauen
  • 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 Count sollte man auch GDI Objects / USER Objects beobachten
  • 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

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

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

Diese Fallstudien zeigen einen ähnlichen Ansatz für Analyse, Priorisierung oder Neugestaltung.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

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.

Zurück zum Blog