Entscheidungstabelle: Nach einer unerwarteten Ausnahme beenden oder fortsetzen?

· · Windows-Entwicklung, Ausnahmebehandlung, Design, C# / .NET, Zuverlässigkeit

Excel-Checkliste mit japanisch-englischen Tabellenblättern herunterladen

Diese Datei besteht aus den beiden Tabellenblättern Checklist-ja und Checklist-en und zerlegt die Entscheidungsachsen dieses Artikels in 4 Kategorien mit 27 Punkten: Fortsetzungsbedingungen / Beendigungsbedingungen / Umsetzungsansatz / Entscheidungsreihenfolge. Die Spalten Status und Notes bleiben leer, sodass sich die Datei direkt als Aufzeichnung für die Störungsbehebung oder als Checkliste für Design-Reviews verwenden lässt.

Bei unerwarteten Ausnahmen denkt man schnell in einer Zweiteilung: abstürzen lassen oder mit catch weitermachen. In der Praxis ist diese Zweiteilung allerdings etwas grob.

Was Sie eigentlich wissen wollen, ist, ob sich der möglicherweise beschädigte Bereich eingrenzen lässt.

  • Lässt sich nur diese eine Operation als fehlgeschlagen beenden?
  • Reicht es, nur diesen Bildschirm / diese Verbindung / diesen Worker neu zu initialisieren?
  • Oder ist bereits die Integrität des gesamten Prozesses fraglich?

Betrachtet man es in dieser Reihenfolge, lässt es sich deutlich leichter ordnen.

Dieser Artikel stellt, ausgehend von C#-/.NET-Windows-Anwendungen, dauerhaft laufenden Anwendungen, Windows-Diensten und Werkzeugen zur Gerätekopplung, eine Entscheidungstabelle für die Bedingungen zusammen, unter denen nach einer unerwarteten Ausnahme fortgesetzt werden darf, und die Bedingungen, unter denen ein Beenden vorzuziehen ist.

1. Zunächst das Fazit

  • Alles mit catch (Exception) abzufangen und weiterzumachen ist in der Regel gefährlich.
  • Fortsetzen ist nur vertretbar, wenn drei Dinge zusammenkommen: die fehlgeschlagene Einheit lässt sich verwerfen, der gemeinsame Zustand lässt sich zurücksetzen, externe Nebenwirkungen lassen sich erklären.
  • Ist die Verarbeitungsgrenze klar – eine UI-Operation, eine Eingabe, ein Job –, kann mitunter fortgesetzt werden.
  • Sind dagegen gemeinsamer, veränderlicher Zustand, dauerhaft laufende Schleifen, der Hauptthread, Startvorgänge, native Grenzen oder ein Verdacht auf Speicherbeschädigung im Spiel, ist das Beenden vorzuziehen.
  • Bei Ausnahmen wie StackOverflowException, AccessViolationException oder OutOfMemoryException, die die Gesundheit des gesamten Prozesses infrage stellen, ist es sicherer, nicht von einer Fortsetzung auszugehen.
  • WPF und Windows Forms bieten zwar Wege, unbehandelte Ausnahmen abzufangen und scheinbar weiterzumachen, aber fortsetzen können und sicher fortsetzen sind zwei verschiedene Dinge.
  • Bei lange laufenden Diensten und Überwachungsanwendungen ist es oft sicherer und leichter zu diagnostizieren, abzustürzen und neu gestartet zu werden, als halb kaputt weiterzulaufen.

Kurz gesagt: Die entscheidende Achse ist, ob sich die Invarianten wiederherstellen lassen.

1.1 In diesem Artikel verwendete Begriffe

Bevor Sie die Entscheidungstabelle lesen, klären wir vorab nur die 5 Begriffe, die immer wieder vorkommen.

Begriff Bedeutung in diesem Artikel
Invariante Ein Zustandsversprechen, das vor und nach der Verarbeitung immer gelten muss. Beispiele: „Die Summe der Posten stimmt mit dem Aggregatwert überein“, „Cache und Datenbankinhalt stimmen überein“, „Eine geöffnete Verbindung steht immer in der Verwaltungsliste“. Bricht das, ist alles Nachfolgende verdächtig
Fehlereinheit Der Bereich, der bei einem Fehlschlag komplett verworfen werden kann. Eine Operation, ein Bildschirm, ein Job, eine Verbindung usw.
Externe Nebenwirkung Eine Änderung, die den Prozess bereits verlassen hat. DB-Aktualisierungen, Dateischreibvorgänge, E-Mail-Versand, Befehle an ein Gerät – alles, was sich durch catch nicht zurücknehmen lässt
Subsystem Eine Einheit, die sich gemeinsam stoppen und neu initialisieren lässt. Verbindung, Bildschirm, Worker, Kindprozess usw.
FailFast Steht für Environment.FailFast. Eine API, die den Prozess sofort beendet, ohne try / finally oder Finalizer auszuführen; Details dazu in Abschnitt 9.7

2. Was dieser Artikel unter einer „unerwarteten Ausnahme“ versteht

2.1 Erwartet und unerwartet trennen

Zunächst: Eine seltene Ausnahme und eine unerwartete Ausnahme sind nicht dasselbe.

Zum Beispiel lässt sich Folgendes trotz geringer Häufigkeit als erwartet behandeln:

  • Der Benutzer hat eine nicht existierende Datei gewählt
  • Die Gegenstelle hat vorübergehend ein Timeout ausgelöst
  • Eine Zeile der importierten CSV-Datei war fehlerhaft
  • Ein Abbruch hat OperationCanceledException ausgelöst
  • Ein Verstoß gegen eine Geschäftsregel soll genau diesen einen Vorgang fehlschlagen lassen

Das sind Fehler, bei denen der Umgang mit dem Fehlschlag schon im Design festgelegt werden kann.

Die unerwarteten Ausnahmen, um die es in diesem Artikel hauptsächlich geht, sehen dagegen so aus:

  • Eine Annahme im eigenen Code ist zusammengebrochen, und es kam zu NullReferenceException oder InvalidOperationException
  • Mitten in der Aktualisierung eines gemeinsamen Zustands ist eine Ausnahme aufgetreten, und es ist unklar, wie weit die Änderung übernommen wurde
  • Die übergeordnete Schleife einer Überwachungs- oder Nachrichtenverarbeitung ist abgestürzt
  • An der Grenze zu COM / P/Invoke / einem Drittanbieter-SDK ist eine Störung aufgetreten
  • Wie bei AccessViolationException oder StackOverflowException schlägt bereits der grundlegende Gesundheitscheck des Prozesses fehl

Kurz gesagt: Es sind Fälle, in denen „nach dieser Ausnahme unklar ist, ob dem Zustand der Anwendung noch vertraut werden kann“.

2.2 Es sieht wie eine Zweiteilung aus, ist aber eigentlich eine Dreiteilung

Was dieses Thema unnötig verkompliziert, ist, „Fortsetzen“ als eine einzige Option zu behandeln.

In der Praxis lässt es sich meist in drei Stufen unterteilen.

Wahl Bedeutung
Nur diese Operation fehlschlagen lassen und fortsetzen Der Bildschirm bleibt erhalten, aber nur dieser Speicher- oder Importvorgang gilt als fehlgeschlagen
Nur das Subsystem stoppen und fortsetzen Nur Verbindung, Bildschirm, Worker oder Kindprozess neu initialisieren
Den Prozess beenden Der Umfang der Zustandsbeschädigung lässt sich nicht mehr abschätzen, also von einem Neustart ausgehen

„Die Anwendung setzt fort“ kann zwei sehr unterschiedliche Dinge bedeuten: so weitermachen, als wäre nichts gewesen, und den defekten Teil abtrennen und dann fortsetzen.

3. Die Entscheidungstabelle, die Sie zuerst ansehen sollten

3.1 Der Gesamtüberblick

Ausgehend von dieser Tabelle lässt sich die grobe Richtung meist schon festlegen.

Situation Erste Wahl Grund
Nur eine Eingabe, eine Bildschirmoperation oder ein Job ist fehlgeschlagen, und der Zustand lässt sich verwerfen Eher fortsetzen Die Fehlereinheit lässt sich eingrenzen
Nach der Ausnahme lassen sich das betroffene Objekt oder die Verbindung verwerfen und neu aufbauen Eher Subsystem neu initialisieren Der beschädigte Bereich lässt sich lokalisieren
Der gemeinsame Zustand wurde teilweise aktualisiert, aber es ist unklar, wie weit die Änderung übernommen wurde Eher beenden Die Invarianten könnten gebrochen sein
Externe Nebenwirkungen wie DB / Datei / Gerätebefehl sind unvollständig, und Duplikate oder fehlende Übernahmen lassen sich nicht erklären Eher beenden Die Konsistenz mit der Außenwelt lässt sich nicht abschätzen
Die Überwachungsschleife, die Wiederverbindungsschleife oder die übergeordnete Schleife der Nachrichtenverarbeitung ist wegen einer unerwarteten Ausnahme abgestürzt Eher beenden Stillschweigend nur eine Funktion sterben zu lassen führt leicht zur Zombifizierung
Startvorgang, Konfigurationsladen, DI-Zusammenstellung oder Initialisierung einer notwendigen Abhängigkeit ist fehlgeschlagen Eher als Startfehler beenden Ein halb gestarteter Zustand ist gefährlicher
AccessViolationException, StackOverflowException, eine schwerwiegende OutOfMemoryException oder ein Verdacht auf Beschädigung auf nativer Seite Eher sofort beenden Die Gesundheit des gesamten Prozesses ist fraglich
Die gefährliche Verarbeitung ist in einem separaten Prozess isoliert, der übergeordnete Prozess bleibt unversehrt Übergeordneter Prozess läuft weiter, Kindprozess neu starten Der Störungsbereich ist bereits getrennt
JaNeinNeinJaNeinJaNeinJaUnerwartete AusnahmeVerdacht auf Speicherbeschädigung / Stapelüberlauf / fatale Ressourcenerschöpfung?Beenden / FailFast / NeustartLässt sich die fehlgeschlagene Einheit verwerfen?Eher beendenLässt sich der gemeinsame Zustand zurücksetzen / neu initialisieren?Subsystem stoppen oder beendenLassen sich externe Nebenwirkungen erklären?Fortsetzen, nur diese Operation fehlgeschlagen

Der „Verdacht auf Speicherbeschädigung“ in der ersten Verzweigung des Diagramms wird in Abschnitt 3.4 behandelt, FailFast oben rechts konkret in Abschnitt 9.7.

3.2 Was vor dem Ausnahmetyp zu prüfen ist

Es ist besser, sich nicht allein anhand des Ausnahmetyps sofort festzulegen. Zuerst sollten Sie Folgendes prüfen.

Zu prüfen Was Sie klären
Wo es passiert ist UI-Ereignis, ein einzelner Job, übergeordnete Schleife, Startvorgang oder native Grenze
Wie weit es fortgeschritten ist Ob sich Speicherzustand, DB, Dateien oder Gerätezustand unterwegs schon geändert haben
Möglicher Schadensbereich Nur dieses Objekt, der ganze Bildschirm oder der ganze Prozess
Ist ein Rollback möglich? Lässt es sich verwerfen und neu aufbauen, oder per Transaktion zurücksetzen
Externe Nebenwirkungen Gesendet oder nicht gesendet, ob doppelte Ausführung sicher ist, ob eine Kompensation möglich ist
Überwachung / Neustart Gibt es nach dem Beenden einen automatischen Neustart oder einen Wiederherstellungspfad

3.3 Besonders riskante Ausnahmen

Sie müssen nicht jeden Ausnahmetyp im Detail durchgehen, aber es gibt einige, die man besser nicht mit einer Fortsetzung im Hinterkopf betrachtet.

Ausnahme / Anzeichen Erste Wahl Grund
StackOverflowException Eher sofort beenden Der Aufrufstapel ist zusammengebrochen, eine normale Wiederherstellung ist kaum anzunehmen
AccessViolationException Eher sofort beenden Unzulässiger Zugriff auf geschützten Speicher; native Grenzen oder Speicherbeschädigung sind zu vermuten
OutOfMemoryException Eher beenden Wiederherstellungscode, der selbst weitere Zuweisungen benötigt, neigt zur Instabilität
Unerwartete NullReferenceException / InvalidOperationException Kontextabhängig, aber eher beenden Eine eigene Annahme ist zusammengebrochen, teilweise Änderungen könnten übrig geblieben sein
Eine unerwartete Ausnahme, die aus der übergeordneten Schleife entwichen ist Eher beenden Der Kern der Funktion ist tot, während der Prozess selbst gefährlich weiterlebt
Störung mit Ursprung in COM- / P/Invoke- / Drittanbieter-SDK-Callbacks Sofort bis stark beenden Die Sicherheit lässt sich allein von der Managed-Seite aus schwer beurteilen

3.4 Woran man „Verdacht auf Speicherbeschädigung“ und „Zombifizierung“ tatsächlich erkennt

Diese beiden Begriffe klingen zwar nach Bauchgefühl, aber worauf man dabei tatsächlich schaut, ist klar festgelegt.

Zunächst die Symptome, die eine Speicherbeschädigung vermuten lassen.

Was zu sehen ist Wo Sie es sehen
Der Prozess stürzt mit dem Ausnahmecode 0xc0000005 (Zugriffsverletzung) oder 0xc0000374 (Heap-Beschädigung) ab Ereignisanzeige > Windows-Protokolle > Anwendung, Eintrag „Anwendungsfehler“ (Ereignis-ID 1000)
Die Absturzstelle wechselt jedes Mal. Der Absturz erfolgt in Code, der zuletzt gar nicht angefasst wurde Log, Aufrufstapel des Dumps
Der Absturz tritt nur auf, wenn ein bereits freigegebenes Objekt oder Handle angefasst wird Reproduktionsschritte, Dump
Der Absturz erfolgt bei einer nativen Freigaberoutine (free / delete / COM-Freigabe) Aufrufstapel des Dumps (z. B. ein Absturz innerhalb von ntdll.dll)
Werte, die nicht angefasst wurden, sind verändert. Dasselbe Eingabemuster liefert unterschiedliche Ergebnisse Ein-/Ausgabelogs, Vergleich wiederholter Läufe

0xc0000005 steht für STATUS_ACCESS_VIOLATION, 0xc0000374 für STATUS_HEAP_CORRUPTION – beides Werte, die in Microsofts NTSTATUS-Liste definiert sind. Besonders bei einer Heap-Beschädigung stürzt der Prozess nicht in dem Moment ab, in dem der Schaden entsteht, sondern dort, wo als Nächstes auf den beschädigten Heap zugegriffen wird – die Absturzstelle ist also nicht zwangsläufig die Ursache. Zeigt sich dieses Muster, ist es sicherer, davon auszugehen, dass ein catch auf Managed-Seite mit anschließendem Weiterlaufen keinen Sinn ergibt.

Als Nächstes die Symptome, die eine Zombifizierung vermuten lassen.

Was zu sehen ist Wo Sie es sehen
Der Prozess lebt, aber der Zeitpunkt der letzten Verarbeitung wird nicht aktualisiert Log des letzten Verarbeitungszeitpunkts, Heartbeat
Nur die Anzahl der Warteschlangen- oder Eingangsordner-Einträge wächst immer weiter Warteschlangenlänge, Anzahl unverarbeiteter Dateien
Ab einem bestimmten Zeitpunkt bricht das Log plötzlich ab Anwendungslog
Der Bildschirm lässt sich bedienen, aber die Aktualisierung im Hintergrund steht still Abgleich von Bildschirmanzeige und tatsächlichen Daten
Die Anzahl der Worker-Threads ist geringer als erwartet Diagnoselog, Thread-Liste im Process Explorer

Zombifizierung bedeutet nicht, dass der Prozess abgestürzt ist, sondern dass er lebt, ohne zu arbeiten. Bereiten Sie im Voraus Kennzahlen vor, die „laufende Arbeit“ anzeigen – letzter Verarbeitungszeitpunkt, Warteschlangenlänge, Heartbeat –, dann fällt sowohl die Entscheidung zwischen Fortsetzen und Beenden als auch die spätere Untersuchung deutlich leichter.

4. Nach dem Ort der Ausnahme entscheiden

4.1 UI-Ereignisse

UI-Ereignisse wie Button-Klicks, Bildschirmwechsel, Suche oder Dateiauswahl bieten vergleichsweise viel Spielraum zum Fortsetzen. Es gibt jedoch Bedingungen.

Leichter fortzusetzen ist es in Fällen wie diesen:

  • Der Fehler ist vor dem Laden aufgetreten, der fachliche Zustand wurde noch nicht angefasst
  • Nur ein temporärer Zustand innerhalb eines Dialogs ist defekt, der beim Schließen des Dialogs verworfen wird
  • ViewModel oder Verbindung lassen sich nach der Ausnahme neu aufbauen
  • Dem Benutzer kann ehrlich mitgeteilt werden, dass „diese Operation fehlgeschlagen ist“

Umgekehrt neigt es zum Beenden, sobald es so aussieht:

  • Sowohl Bildschirm als auch Domänenzustand wurden teilweise aktualisiert
  • Ein gemeinsamer Zustand wie static / Singleton / Cache, den auch andere Bildschirme sehen, wurde angefasst
  • Nach der Ausnahme bleiben nur Button-Aktivierung oder Auswahlzustand übrig, deren Konsistenz unklar ist
  • Eine unerwartete Ausnahme ist auf dem UI-Thread aufgetreten, und es ist unklar, wie weit Rendering oder Benachrichtigungen fortgeschritten sind

4.2 Einzeln verarbeitete Jobs / Anfragen

Das ist eine Grenze, an der sich leicht fortsetzen lässt.

  • Eine Nachricht
  • Eine Datei
  • Eine HTTP-Anfrage
  • Ein Importjob
  • Ein Batch-Element

Sind solche Einheiten klar abgegrenzt, können Sie nur dieses eine Element fehlschlagen lassen und mit dem nächsten fortfahren.

Es gibt jedoch Voraussetzungen:

  • Die Fehlereinheit ist von außen klar erkennbar
  • Teilweise Änderungen werden durch Transaktionen oder Kompensation bereinigt
  • Ein erneuter Durchlauf derselben Verarbeitung führt zu keinem beschädigten Ergebnis
  • Fehler lassen sich in eine Quarantäne-Warteschlange oder ein Fehlerprotokoll auslagern

4.3 Dauerhaft laufende Schleifen / Überwachung / Warteschlangenverarbeitung

Das ist die Stelle, an der ein unbedachtes Fortsetzen am gefährlichsten ist.

Zum Beispiel:

  • Wiederverbindungsschleifen
  • Überwachungsschleifen
  • Schleifen zum Konsumieren von Warteschlangen
  • Regelmäßiges Polling
  • Überwachung des Gerätezustands
  • Dauerhafte Verarbeitung in einer Tray-Anwendung

Bei dieser Art von Verarbeitung ist das Beängstigende, dass die übergeordnete Schleife durch eine einzige unerwartete Ausnahme stirbt, während der Prozess allein überlebt.

Hier lohnt es sich, die Vorgehensweise zu trennen:

  • Erwartete Ausnahmen an der Grenze der einzelnen Elementverarbeitung abfangen
  • Entweicht eine unerwartete Ausnahme aus der übergeordneten Schleife, eher den Prozess beenden

4.4 Startvorgang

Behandelt man einen Fehler beim Start nach dem Motto „erst mal starten und dann weitersehen“, läuft die Anwendung mit fehlenden Funktionen weiter, und es wird später schwer, die Ursache einzugrenzen.

  • Erforderliche Konfiguration lässt sich nicht lesen
  • Versionsmigration ist fehlgeschlagen
  • Ein erforderlicher Ordner oder ein Zertifikat fehlt
  • Die Initialisierung eines Kerndienstes ist fehlgeschlagen
  • Die Abhängigkeitskonfiguration ist defekt

In solchen Fällen ist als Startfehler beenden die klarere Wahl.

4.5 Native Grenzen / COM / P/Invoke / unsafe

Hier lohnt sich ein separater, etwas strengerer Blick.

  • COM
  • P/Invoke
  • Was hinter C++/CLI liegt
  • Drittanbieter-SDKs
  • Nativer Code, der über einen Callback zurückkommt
  • Verarbeitung mit unsafe

Besonders bei folgenden Anzeichen ist das Beenden vorzuziehen:

  • AccessViolationException
  • Anzeichen, die auf Heap-Beschädigung oder einen doppelten Free hindeuten
  • Handle-Anomalien, Verdacht auf Zugriff nach Freigabe
  • Plötzlicher Absturz an der Callback-Grenze

5. Bedingungen, unter denen Fortsetzen vertretbar ist

Zusammengefasst sehen die Bedingungen, unter denen Fortsetzen vertretbar ist, so aus. Voraussetzung ist, dass diese im Wesentlichen zusammen erfüllt sind.

Bedingung Bedeutung
Die Fehlereinheit ist klar Eine Operation, ein Bildschirm, ein Job, eine Verbindung – Sie wissen, was verworfen wird
Der Zustand lässt sich verwerfen Er lässt sich verwerfen und neu aufbauen, oder als nicht übernommen behandeln
Der gemeinsame Zustand ist geschützt Die Verunreinigung breitet sich nicht auf andere Funktionen aus
Externe Nebenwirkungen lassen sich erklären Sie wissen, ob gesendet / nicht gesendet / ein erneutes Senden sicher ist
Sie können dem Benutzer ehrlich Bescheid geben Sie können anzeigen, dass „dieser Vorgang fehlgeschlagen ist“
Es lässt sich überwachen Logs, Metriken und Dumps ermöglichen eine nachträgliche Untersuchung

6. Bedingungen, unter denen Beenden vorzuziehen ist

Umgekehrt ist das Beenden vorzuziehen, wenn Folgendes zutrifft.

  • Unklar ist, was unterwegs alles geändert wurde
  • Gemeinsamer, veränderlicher Zustand wurde angefasst, und die Konsistenz lässt sich nicht abschätzen
  • Die Lebenszyklusverwaltung von Sperren, Warteschlangen, Threads oder Überwachungsschleifen ist defekt
  • Doppelte / fehlende / unvollständige externe Nebenwirkungen lassen sich nicht erklären
  • Der Startvorgang oder die Initialisierung der Kerninfrastruktur ist fehlgeschlagen
  • Native Grenzen oder eine Speicherbeschädigung stehen im Verdacht

Auf dieser Stufe wirkt eine Vorkehrung, die das Wiederherstellen nach einem Absturz erleichtert, besser als ein Kunstgriff für ein sauberes Fortsetzen.

7. Empfehlungen nach typischen Mustern

Die Entscheidungstabelle aus 3.1 ist in Form von Bedingungen formuliert, sodass es beim Anwenden auf eine konkrete Situation zu Unsicherheit kommen kann. Diese Tabelle ordnet diese Bedingungen häufig anzutreffenden Situationen zu. Im Zweifel ist es schneller, zunächst mit 3.1 die Bedingungen zu prüfen und danach in dieser Tabelle die passende Zeile zu suchen.

Muster Empfehlung Grund
Über den „Datei öffnen“-Button wurde ein nicht existierender Pfad angegeben Nur diese Operation fehlschlagen lassen, fortsetzen Die Zustandsbeschädigung ist lokal begrenzt
Nur eine Zeile des CSV-Imports war defekt Eine Zeile oder eine Datei fehlschlagen lassen, fortsetzen Die Fehlereinheit lässt sich leicht eingrenzen
Beim Speichern eines Bildschirms trat mitten im Vorgang eine unerwartete NullReferenceException auf Bildschirm neu aufbauen bis eher beenden Unklar, wie weit ViewModel / fachlicher Zustand geändert wurden
Eine Warteschlangennachricht verletzte eine Geschäftsregel Nur diese Nachricht fehlschlagen lassen, fortsetzen Sie lässt sich in eine Quarantäne-Warteschlange auslagern
Die übergeordnete Schleife der Warteschlangenverarbeitung ist wegen einer unerwarteten Ausnahme abgestürzt Eher den Prozess beenden Der Lebenszyklus des gesamten Workers ist defekt
Beim Start lässt sich eine erforderliche Konfiguration nicht lesen Als Startfehler beenden Ein halb gestarteter Zustand ist gefährlicher
AccessViolationException rund um einen Drittanbieter-SDK-Callback Eher sofort beenden Die Möglichkeit einer Speicherbeschädigung lässt sich nicht ausschließen
Nur eine nicht wesentliche Telemetrieübertragung ist fehlgeschlagen Nur diese Funktion deaktivieren, fortsetzen Störungsbereich lässt sich von der Hauptfunktion trennen

8. Häufige Fallstricke

8.1 catch (Exception), das nur ein Log ausgibt und weitermacht

Das ist ziemlich gefährlich. Es verdeckt die Ursache und verlängert leicht das Leben eines beschädigten Zustands.

8.2 Der Versuch, im letzten unbehandelten Ausnahme-Handler wiederherzustellen

AppDomain.UnhandledException, Application.ThreadException, DispatcherUnhandledException und Ähnliches sind als letzte Aufzeichnungsstelle nützlich, aber kein magischer Wiederherstellungspunkt.

8.3 Bei vorhandenen externen Nebenwirkungen unbedacht erneut versuchen

Wird bei Gerätebefehlen, E-Mail-Versand, Abrechnungen, Dateiverschiebungen oder DB-Aktualisierungen ohne Sicherheit für eine erneute Ausführung ein Retry durchgeführt, rückt diesmal ein Vorfall mit doppelter Ausführung in den Vordergrund.

8.4 Die UI am Leben lassen, obwohl die Überwachungsschleife tot ist

Eine Anwendung, die nur äußerlich lebt, aber keine Arbeit mehr verrichtet, wirkt auf den Benutzer normal, wodurch die Entdeckung sich verzögert. Es lohnt sich, die in 3.4 genannten Symptome der Zombifizierung von der Überwachungsseite aus erfassbar zu machen.

8.5 „Wir wollen keinen Absturz“ sagen, ohne dafür zu entwerfen

Wenn Sie keinen Absturz wollen, gibt es Dinge, die vorher eingerichtet werden müssen.

  • Automatischer Neustart
  • Sitzungswiederherstellung
  • Speichern von Zwischenergebnissen
  • Sicherheit für eine erneute Ausführung
  • Trennung des Störungsbereichs

9. Ordnungspunkte bei der Umsetzung

9.1 catch-Stellen an die Grenzen verlagern

Statt in tiefen Schichten alles Mögliche abzufangen, lässt es sich leichter ordnen, wenn Sie dort abfangen, wo sich die Fehlereinheit definieren lässt, etwa:

  • UI-Operationsgrenze
  • Grenze einer Anfrage
  • Grenze eines Jobs
  • Grenze einer Verbindung
  • Prozessgrenze

Bei einem Import, der Element für Element verarbeitet wird, platzieren Sie das catch zum Beispiel innerhalb der Schleife. Das ist hier die Fehlereinheit.

// C# / .NET 8. Beispiel für eine Schleife, die Element für Element verarbeitet
// und die Fehlereinheit im Inneren der Schleife einschließt.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;

public sealed record ImportItem(string Id, string Payload);
public sealed record ImportFailure(string Id, string ExceptionType, string Message);
public sealed record ImportSummary(int Succeeded, IReadOnlyList<ImportFailure> Failed);

public interface IImportStore
{
    /// <summary>
    /// Ein Fehlschlag für ein einzelnes Element (Validierungsfehler, Duplikat,
    /// falsches Format usw.) wird als ImportItemException geworfen.
    /// Alles andere ist nicht „nur ein Problem dieses einen Elements“ und soll
    /// unverändert nach außen durchgereicht werden.
    /// </summary>
    Task SaveAsync(ImportItem item, CancellationToken cancellationToken);
}

/// <summary>Ein Fehlschlag für ein einzelnes Element, der verworfen werden darf, um weiterzumachen.</summary>
public sealed class ImportItemException(string message, Exception? inner = null)
    : Exception(message, inner);

public sealed class ImportRunner(IImportStore store, ILogger<ImportRunner> logger)
{
    public async Task<ImportSummary> RunAsync(
        IReadOnlyList<ImportItem> items,
        CancellationToken cancellationToken)
    {
        int succeeded = 0;
        List<ImportFailure> failed = [];

        foreach (ImportItem item in items)
        {
            cancellationToken.ThrowIfCancellationRequested();

            try
            {
                await store.SaveAsync(item, cancellationToken);
                succeeded++;
            }
            catch (ImportItemException ex)
            {
                // Der Zustand für dieses eine Element lässt sich verwerfen,
                // also aufzeichnen und mit dem nächsten Element weitermachen.
                //
                // Machen Sie hier kein catch (Exception) daraus. Würde man
                // sogar NullReferenceException oder OutOfMemoryException als
                // „bloß fehlerhafte Daten“ verschlucken, erreichte die
                // unerwartete Ausnahme, für die in 4.3 „den Host mitsamt
                // stoppen“ festgelegt wurde, die übergeordnete Schleife nicht
                // mehr. Es würde weiter geschrieben, obwohl dem Zustand nicht
                // mehr zu trauen ist
                logger.LogError(ex, "Import failed. ItemId={ItemId}", item.Id);
                failed.Add(new ImportFailure(item.Id, ex.GetType().Name, ex.Message));
            }
        }

        return new ImportSummary(succeeded, failed);
    }
}

Umgekehrt gehört dazu, dass die übergeordnete Schleife, die diese Verarbeitung antreibt, nichts abfängt und verschluckt. Wie in 4.3 beschrieben, ist der schlimmste Fall, dass die übergeordnete Schleife stirbt, während der Prozess allein bestehen bleibt, weshalb eine unerwartete Ausnahme hier zum Stoppen des Hosts führt.

// Seite der dauerhaft laufenden Schleife. Ein Stopp-Wunsch wird als
// Normalfall verlassen, jede andere unerwartete Ausnahme stoppt den Host mit.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

public interface IImportQueue
{
    Task<IReadOnlyList<ImportItem>> DequeueBatchAsync(CancellationToken cancellationToken);
}

public sealed class ImportWorker(
    IImportQueue queue,
    ImportRunner runner,
    IHostApplicationLifetime lifetime,
    ILogger<ImportWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            using PeriodicTimer timer = new(TimeSpan.FromSeconds(10));

            while (await timer.WaitForNextTickAsync(stoppingToken))
            {
                IReadOnlyList<ImportItem> batch = await queue.DequeueBatchAsync(stoppingToken);
                ImportSummary summary = await runner.RunAsync(batch, stoppingToken);

                logger.LogInformation(
                    "Batch finished. Succeeded={Succeeded} Failed={Failed}",
                    summary.Succeeded,
                    summary.Failed.Count);
            }
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
        {
            // Ein Stopp-Wunsch ist der Normalfall. Hier still enden.
        }
        catch (Exception ex)
        {
            // Die übergeordnete Schleife ist defekt = der Lebenszyklus des
            // gesamten Workers ist defekt.
            // Nicht verschlucken und einen Zustand erzeugen, in dem „nur der
            // Prozess noch lebt“, sondern stoppen und dem Neustart übergeben.
            logger.LogCritical(ex, "Worker loop failed unexpectedly. Stopping the host.");

            // StopApplication ist eine Anforderung zum „sauberen Einklappen“.
            // Allein damit lässt es sich von der Überwachungsseite, die nur
            // bei einem Fehlschlag neu startet (Wiederherstellungsmaßnahmen
            // eines Dienstes, Restart=on-failure bei systemd, die
            // Neustartrichtlinie eines Containers), nicht von einem
            // „ordnungsgemäß beendeten Auftrag“ unterscheiden, und der Worker
            // steht nie wieder auf.
            //
            // Auch das bloße Setzen von Environment.ExitCode reicht nicht.
            // Läuft die Anwendung als Windows-Dienst, meldet der Host bei
            // sauberem Stopp SERVICE_STOPPED an den SCM, und ExitCode
            // spiegelt sich nicht im Beendigungsstatus des Dienstes wider,
            // sodass die Wiederherstellungsmaßnahme nicht greift. Beenden Sie
            // den Prozess daher mit einem Exit-Code ungleich 0
            Environment.Exit(1);
        }
    }
}

Entscheidend ist die Verwendung von Environment.Exit(1). IHostApplicationLifetime.StopApplication() ist eine Anforderung für einen normalen Stopp. Läuft die Anwendung als Windows-Dienst, wird dem SCM daher SERVICE_STOPPED gemeldet. Da sich der Environment.ExitCode des Prozesses nicht im Beendigungsstatus des Dienstes widerspiegelt, greift die in den Diensteigenschaften konfigurierte Wiederherstellungsmaßnahme („Dienst neu starten“) nicht. Auch das offizielle Tutorial zum Worker-Dienst hält ausdrücklich fest: Das voreingestellte BackgroundServiceExceptionBehavior.StopHost stoppt den Host „sauber“, weshalb die Windows-Dienstverwaltung ihn nicht neu startet; um die Wiederherstellungsmaßnahme greifen zu lassen, muss Environment.Exit mit einem Exit-Code ungleich 0 aufgerufen werden (siehe Quellen in Kapitel 11).

Environment.Exit beendet den aktuellen Prozess, also schreiben Sie alle Logs, die Sie vor dem Absturz haben möchten, unbedingt vor dieser Zeile fertig. Verwenden Sie einen puffernden Logger, fügen Sie ein Flush ein. Läuft die Anwendung dagegen nicht als Windows-Dienst, sondern nur unter systemd oder in einem Container, lässt sich die Überwachungsseite auch behandeln, indem Sie Environment.ExitCode setzen und dann mit StopApplication() einklappen – sie erkennt das ebenfalls als Fehlschlag. Wählen Sie je nach Umgebung, in der die Anwendung läuft.

Beachten Sie außerdem, dass sich die Rolle von catch innen und außen unterscheidet: innen die Aufzeichnung der Fehlereinheit, außen die Beendigung des Lebenszyklus. Vertauscht man das, stürzt entweder die Anwendung wegen eines einzelnen Fehlschlags ab, oder umgekehrt bleibt der Prozess bestehen, obwohl der Worker tot ist.

9.2 Erwartete und unerwartete Ausnahmen trennen

  • Erwartet: Validierung, Nicht gefunden, Timeout, Abbruch, Verstoß gegen Geschäftsregeln
  • Unerwartet: Gebrochene Annahmen, Entweichen aus der übergeordneten Schleife, Störungen an nativen Grenzen, Verdacht auf Speicherbeschädigung

9.3 Gemeinsamen Zustand klein halten

Je größer der gemeinsame, veränderliche Zustand ist, desto schwieriger wird die Entscheidung über das Fortsetzen. Umgekehrt gilt: Je mehr sich in einen Bildschirm, eine Sitzung, einen Worker einschließen lässt, desto leichter lässt sich auch der Fehler eingrenzen.

9.4 Gefährliche Verarbeitung in einen separaten Prozess auslagern

Bei COM / ActiveX / Drittanbieter-SDKs / unsafe / aufwendiger Bildverarbeitung / externer Gerätesteuerung – überall dort, wo sich der Schaden bei einem Absturz nicht ausbreiten soll – bringt eine Auslagerung in einen separaten Prozess erheblich viel.

9.5 Unbehandelte-Ausnahme-Handler dienen der „Aufzeichnung“, nicht der „Wiederherstellung“

  • Ausnahmeinformationen
  • Verarbeitungskontext
  • Wichtige Logeinträge unmittelbar davor
  • Konfiguration / Version / Verbindungsziele
  • Weg zur Dump-Erfassung

Diese Punkte bereitzustellen und eine Form zu bevorzugen, mit der sich nach dem Absturz weiter ermitteln lässt, führt im Ergebnis zu mehr Stabilität.

In WPF reicht für den Aufzeichnungs-Handler etwa diese Form aus.

// App.xaml.cs in WPF (.NET 8). Der Handler dient der „Aufzeichnung“, nicht der „Wiederherstellung“.
using System;
using System.IO;
using System.Reflection;
using System.Threading.Tasks;
using System.Windows;

namespace SampleApp;

public partial class App : Application
{
    private static readonly string CrashLogPath = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
        "SampleApp",
        "crash.log");

    protected override void OnStartup(StartupEventArgs e)
    {
        // Die Handler-Registrierung steht vor base.OnStartup(e).
        // base.OnStartup löst das Startup-Ereignis aus. Tritt dort auf der
        // Abonnentenseite eine Ausnahme auf, ist der weiter unten
        // geschriebene Handler noch nicht registriert, und es entsteht der
        // schlimmste Fall: „beim Start abgestürzt, aber keine einzige
        // Logzeile vorhanden“. Da gerade der Fehlschlag des Startvorgangs
        // selbst aufgezeichnet werden soll, wird der Handler vorher gesetzt.

        // Auf dem UI-Thread unbehandelt aufgestiegene Ausnahme
        DispatcherUnhandledException += (_, args) =>
        {
            Record("DispatcherUnhandledException", args.Exception);

            // Mit args.Handled = true ließe sich fortfahren, aber ob das
            // vertretbar ist, entscheidet sich nach den Bedingungen aus
            // Kapitel 5. Ist das nicht eindeutig zu beurteilen, aufzeichnen
            // und dem Standardverhalten (Beenden) überlassen.
            args.Handled = false;
        };

        // Letzte Benachrichtigung, auch außerhalb des UI-Threads. Hier lässt
        // sich nichts mehr aufhalten, daher nur zur Aufzeichnung.
        AppDomain.CurrentDomain.UnhandledException += (_, args) =>
            Record("AppDomain.UnhandledException", args.ExceptionObject as Exception);

        // Ausnahme eines Task, der ohne await eingesammelt wurde
        TaskScheduler.UnobservedTaskException += (_, args) =>
        {
            Record("UnobservedTaskException", args.Exception);
            args.SetObserved();
        };

        // Erst wenn alle Handler gesetzt sind, mit dem Standardstartvorgang
        // (Auslösen des Startup-Ereignisses) fortfahren
        base.OnStartup(e);
    }

    private static void Record(string source, Exception? exception)
    {
        try
        {
            Directory.CreateDirectory(Path.GetDirectoryName(CrashLogPath)!);

            string version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
            string text = string.Join(
                Environment.NewLine,
                $"[{DateTimeOffset.Now:O}] {source}",
                $"version={version} os={Environment.OSVersion} user={Environment.UserName}",
                exception?.ToString() ?? "(no exception object)",
                string.Empty);

            File.AppendAllText(CrashLogPath, text);
        }
        catch
        {
            // Schlägt die Aufzeichnung fehl, den Beendigungsvorgang trotzdem nicht aufhalten.
        }
    }
}

Wichtig ist, im Handler nicht zu versuchen, den Zustand zu reparieren. Hier soll nur Material hinterlassen werden, mit dem sich dasselbe Phänomen später nachverfolgen lässt.

9.6 Den unbehandelten Ausnahmeereignissen von WPF / WinForms nicht zu viel vertrauen

In WPF lässt sich mit Handled = true in DispatcherUnhandledException nach einer unbehandelten Ausnahme technisch fortfahren. Auch in Windows Forms lässt sich auf dem Haupt-UI-Thread über Application.ThreadException und die Einstellung SetUnhandledExceptionMode wählen, wie angehalten wird.

Ob sich damit fortsetzen lässt, und ob die Bedingungen für eine Wiederherstellung erfüllt sind, sind aber zwei verschiedene Fragen.

9.7 Environment.FailFast nur dann, wenn „Aufräumen gefährlicher ist“

Das in der Flussdiagramm aus 3.1 auftauchende FailFast steht für Environment.FailFast. Das offizielle Dokument beschreibt folgendes Verhalten:

  • Der Prozess wird beendet, ohne laufende try / finally oder Finalizer auszuführen
  • Unter Windows wird die übergebene Nachricht in das Windows-Anwendungsereignisprotokoll geschrieben, und es wird ein Anwendungsdump erstellt, bevor der Prozess endet
  • Nachricht und Ausnahmeinformationen fließen über die Windows-Fehlerberichterstattung auch in die Fehlerberichte an Microsoft ein
  • Wird die Methode unter dem Visual-Studio-Debugger aufgerufen, entsteht eine ExecutionEngineException, und der Managed-Debugging-Assistent für fatalExecutionEngineError greift

Dass finally nicht ausgeführt wird, ist kein Nachteil, sondern der Zweck dieser API. Läuft Aufräumcode, während der Zustand beschädigt ist, kann er den beschädigten Inhalt unverändert in eine Datei oder Datenbank schreiben. Auch die Dokumentation beschreibt: Ist der Zustand der Anwendung nicht mehr reparierbar beschädigt und würde die Ausführung von try / finally oder Finalizern Ressourcen beschädigen, sollte statt Environment.Exit FailFast verwendet werden.

Die Wahl zwischen beiden sieht so aus:

Situation Wahl
Eine Invariante ist gebrochen, und Aufräumcode auszuführen wäre gefährlicher Environment.FailFast
Der Zustand ist gesund, und Sie möchten nach dem Aufräumen beenden Normaler Beendigungsvorgang (z. B. IHostApplicationLifetime.StopApplication)
Sie möchten nur einen Exit-Code zurückgeben und beenden Environment.Exit oder ein return aus Main

Im Code sieht der Einsatz zum Beispiel so aus:

// C# / .NET 8. Stelle, an der erkannt wird, dass eine Invariante des
// gemeinsamen Zustands gebrochen ist.
// Ab hier ist keiner Aufräumroutine mehr zu trauen.
if (cache.Count != store.Count)
{
    Environment.FailFast(
        $"Invariant broken: cache={cache.Count} store={store.Count}",
        new InvalidOperationException("Cache and store are out of sync."));
}

Da der Dump automatisch erfasst wird, erleichtert es die spätere Untersuchung erheblich, wenn Sie in die an FailFast übergebene Nachricht schreiben, welche Invariante bei welchen Werten gebrochen war. Umgekehrt ist es übertrieben, FailFast bei einem erwarteten Fehlschlag wie einem Eingabefehler oder einem Kommunikationsfehler aufzurufen. Dafür gilt wieder das in 9.1 zur Fehlereinheit Gesagte.

10. Zusammenfassung

Bei einer unerwarteten Ausnahme geht es nicht um die Frage „Lässt sich diese Ausnahme abfangen?“, sondern darum, ob dem Zustand der Anwendung danach noch vertraut werden kann.

Als Entscheidungsreihenfolge reicht meist Folgendes aus:

  1. Lässt sich die fehlgeschlagene Einheit verwerfen?
  2. Lässt sich der gemeinsame Zustand zurücksetzen oder neu aufbauen?
  3. Lassen sich externe Nebenwirkungen erklären?
  4. Lässt sich der Gesundheitszustand von Speicher / Threads / nativen Grenzen vertrauen?

Sind Sie sich bei diesen vier Punkten sicher, können Sie fortsetzen. Sind Sie sich nicht sicher, ist Beenden vorzuziehen.

Besonders bei lange laufenden Anwendungen, Überwachungsanwendungen, Diensten und Gerätekopplung gibt es viele Situationen, in denen beschädigt weiterzuleben gefährlicher ist als ehrlich abzustürzen.

Ausnahmebehandlung ist nicht die Kunst, niemals abzustürzen. Es ist die Gestaltung, bei der Schäden klein bleiben, die Anwendung im Schadensfall ehrlich anhält und die Wiederherstellung leichtfällt.

11. 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.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

Technische Beratung und Design-Review

Es geht darum, die Leitlinien zur Ausnahmebehandlung, die Fehlergrenzen, die Neustartstrategie und die Entscheidungskriterien für Fortsetzen oder Beenden zu ordnen – ein Thema, das gut zur technischen Beratung und zum Design-Review passt.

Fehleruntersuchung und Ursachenanalyse

Der Ablauf, nach einer unerwarteten Ausnahme zwischen Fortsetzen und Beenden zu unterscheiden – einschließlich Zustandsbeschädigung und externer Nebenwirkungen –, lässt sich gut im Rahmen der Fehleruntersuchung und Ursachenanalyse angehen.

Häufige Fragen

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

Darf man eine unerwartete Ausnahme nicht einfach mit catch (Exception) abfangen und weitermachen?
Nur ein Log auszugeben und weiterzumachen ist in der Regel gefährlich. Das verdeckt die Ursache und verlängert leicht das Leben eines beschädigten Zustands. Fortsetzen ist nur dann vertretbar, wenn drei Dinge zusammenkommen: Sie können die fehlgeschlagene Einheit verwerfen, Sie können den gemeinsamen Zustand zurücksetzen, und Sie können externe Nebenwirkungen erklären. Die entscheidende Frage lautet nicht „Lässt sich diese Ausnahme abfangen?“, sondern „Kann ich dem Zustand der Anwendung danach noch vertrauen?“
Bei welchen Ausnahmen sollte man sofort beenden?
Bei Ausnahmen wie StackOverflowException, AccessViolationException oder einer schwerwiegenden OutOfMemoryException, die die Gesundheit des gesamten Prozesses infrage stellen, ist es sicherer, nicht von einer Fortsetzung auszugehen. Bei StackOverflowException ist der Aufrufstapel zusammengebrochen, bei AccessViolationException liegt ein unzulässiger Zugriff auf geschützten Speicher vor, der eine Speicherbeschädigung vermuten lässt. Auch Störungen, die ihren Ursprung in COM-, P/Invoke- oder Callbacks von Drittanbieter-SDKs haben, tendieren stark zum Beenden, weil sich die Sicherheit allein von der Managed-Seite aus schwer beurteilen lässt.
Unter welchen Bedingungen darf die Anwendung nach einer Ausnahme fortgesetzt werden?
Voraussetzung ist, dass im Wesentlichen folgende Bedingungen zusammen erfüllt sind: Die fehlgeschlagene Einheit ist eindeutig (eine Operation, ein Bildschirm, ein Job, eine Verbindung – Sie wissen, was verworfen wird), der Zustand lässt sich verwerfen und neu aufbauen, die Verunreinigung breitet sich nicht auf den gemeinsamen Zustand aus, externe Nebenwirkungen lassen sich erklären, dem Benutzer kann ehrlich mitgeteilt werden, dass „dieser Vorgang fehlgeschlagen ist“, und Logs oder Metriken ermöglichen eine nachträgliche Untersuchung. Ist die Verarbeitungsgrenze klar – etwa eine einzelne UI-Operation oder ein einzelner Importjob –, kann fortgesetzt werden. Bei einer unvollständigen Aktualisierung des gemeinsamen Zustands, einer defekten übergeordneten Schleife, Startvorgängen oder Störungen an nativen Grenzen ist dagegen das Beenden vorzuziehen.
Reicht es, in WPF bei DispatcherUnhandledException Handled=true zu setzen, um fortzufahren?
Nach einer unbehandelten Ausnahme fortzufahren ist damit technisch möglich, aber ob man fortfahren kann und ob es sicher ist fortzufahren, sind zwei verschiedene Fragen. Handler wie AppDomain.UnhandledException oder DispatcherUnhandledException sind als letzte Aufzeichnungsstelle nützlich, aber kein magischer Wiederherstellungspunkt. Es ist stabiler, Ausnahmeinformationen, Verarbeitungskontext und einen Weg zur Dump-Erfassung bereitzuhalten und die Untersuchung nach dem Absturz zu priorisieren. Besonders bei lange laufenden Diensten und Überwachungsanwendungen ist es oft sicherer und leichter zu diagnostizieren, abzustürzen und neu zu starten, als halb kaputt weiterzulaufen.

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