1. Einführung
Programme zu schreiben, die niemals abstürzen — ist wie zu glauben, Bugs hätten Angst vor deiner IDE. Je komplexer der Code wird (vor allem in multithreaded und asynchronen Umgebungen), desto einfallsreicher sind die Bugs und desto raffinierter die Fehler. Wenn man Ausnahmen in Threads und Tasks ignoriert, bekommt man leicht Ressourcenlecks, Hänger, Datenverluste oder unerwartete Abstürze Stunden nach dem Start.
In dieser Vorlesung fassen wir die besten Praktiken zur Fehlerbehandlung im multithreaded und asynchronen Programmieren mit C# zusammen. Du lernst, wie man Ausnahmen richtig fängt, wie man einen „Haufen“ gleichzeitiger Fehler (im Stil von AggregateException) auseinander nimmt, was man mit Tasks macht, die „verloren“ gestartet wurden, und warum es gefährlich und sinnlos ist, Ausnahmen zu ignorieren.
Warum ist das nicht so einfach
- Der Thread, in dem ein Fehler auftritt, kann sich vom Thread unterscheiden, in dem das try-catch steht.
- Asynchrone Tasks werfen Ausnahmen nicht sofort — sie werden „verpackt“ und warten auf Verarbeitung beim await (oder beim synchronen Warten).
- Bei gleichzeitiger Arbeit mit mehreren Tasks (z. B. Task.WhenAll) können mehrere Fehler auftreten — diese müssen berücksichtigt werden.
- Operationen wie fire-and-forget können eine Ausnahme vollständig „verlieren“, wenn kein expliziter Handler vorhanden ist.
Dieses Phänomen ist die Aufteilung des Ausführungskontexts. Stell dir das Programm als Zirkus mit mehreren Arenen vor: wenn in einer ein Feuer ausbricht, sieht man das nicht sofort in den anderen. Wichtig ist, solche „Brände“ richtig zu verfolgen und zu löschen.
2. Ausnahmen in Tasks: Task und Task<TResult>
Wie Tasks Fehler signalisieren
Wenn in einer Task eine unbehandelte Ausnahme auftritt, „fliegt“ sie nicht sofort nach außen. Die Task wird Faulted und die Ausnahme wird intern gespeichert. Man kann sie bekommen:
- Indem man auf das Ende mit await wartet (oder über task.Wait()/task.Result — aber das sollte man besser nicht tun);
- Indem man die Eigenschaft task.Exception prüft — dort liegt eine AggregateException.
Beispiel
// Asynchron Methode mit Fehler
async Task FailAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("Chto-to poshlo ne tak");
}
async Task MainAsync()
{
try
{
await FailAsync();
}
catch (Exception ex)
{
Console.WriteLine($"Fehler: {ex.Message}");
}
}
Wenn man kein try-catch setzt, stürzt das Programm ab. Mit einem Handler wird die Ausnahme korrekt gefangen, selbst wenn der Fehler in einer anderen Task aufgetreten ist.
AggregateException: Fehler im Paket
Beim await von Task.WhenAll(tasks) werden Fehler aus mehreren Tasks in einer AggregateException gesammelt (in ihren InnerExceptions).
async Task MultiFailAsync()
{
Task t1 = Task.Run(() => throw new InvalidOperationException("Oshibka 1"));
Task t2 = Task.Run(() => throw new ArgumentException("Oshibka 2"));
try
{
await Task.WhenAll(t1, t2);
}
catch (Exception ex)
{
if (ex is AggregateException agg)
{
foreach (var e in agg.InnerExceptions)
Console.WriteLine($"Ausnahme: {e.Message}");
}
else
{
Console.WriteLine($"Einzelner Fehler: {ex.Message}");
}
}
}
Achtung: bei Verwendung von await Task.WhenAll(tasks) „entpackt“ .NET die AggregateException, und im catch bekommt man die „erste“ Ausnahme. Die vollständige Liste ist über task.Exception.InnerExceptions verfügbar, wenn die Task mit Fehler abgeschlossen wurde.
3. „Fire and Forget“: warum man eine Task nicht einfach vergessen darf
„Starte und vergiss“ führt oft zu versteckten Abstürzen. Beispiel:
Task.Run(() => { throw new Exception("Bakh!"); }); // Der Fehler "verschwindet" ins Leere.
Der moderne Runtime behält die Ausnahme in der Task und kann den Prozess wegen einer unbehandelten Ausnahme beenden. Der beste Weg ist, eine Referenz auf die Task zu speichern und/oder sich auf das Ereignis TaskScheduler.UnobservedTaskException anzumelden.
Wie macht man es richtig?
- Speichere die Task, damit du auf ihr Ende warten und den Fehler behandeln kannst;
- Für fire‑and‑forget setze einen Handler direkt innerhalb des Delegates.
Task.Run(() => {
try
{
// Code, der eine Ausnahme werfen kann
}
catch (Exception ex)
{
// Loggen, benachrichtigen, aber den Fehler nicht nach außen entlassen
Console.WriteLine($"Fehler in fire-and-forget: {ex.Message}");
}
});
4. Fehler in Threads (Thread): „nichts zu fangen?“
Eine Ausnahme in einem neuen Thread kann man nicht mit einem try-catch außenherum fangen — nur innerhalb des Thread-Bodys.
var thread = new Thread(() =>
{
try
{
throw new Exception("Oshibka v potoke");
}
catch (Exception ex)
{
Console.WriteLine($"Fehler im Thread intern gefangen: {ex.Message}");
}
});
thread.Start();
Wenn kein Handler gesetzt wird, beendet die Ausnahme nur diesen Thread (wenn er ein Background-Thread ist: thread.IsBackground = true). Für foreground-Threads kann eine unbehandelte Ausnahme den gesamten Prozess beenden. Setze immer ein try-catch innerhalb des Threads.
Wie gibt man Ergebnis und Fehler aus einem Thread zurück?
- Verwende Queues/Collections, um Ergebnisse/Fehler zu übertragen;
- Ereignismodelle;
- Oder besser: wechsle zu Tasks — mit ihnen ist die Fehlerbehandlung einfacher.
5. Parallele Schleifen: Fehler anders fangen
In parallelen Schleifen werden Fehler aus verschiedenen Zweigen in einer AggregateException aggregiert.
try
{
Parallel.For(0, 5, i =>
{
if (i % 2 == 0)
throw new Exception($"Oshibka v iteratsii {i}");
});
}
catch (AggregateException ex)
{
foreach (var e in ex.InnerExceptions)
Console.WriteLine($"[Parallele Schleife] Fehler: {e.Message}");
}
Wenn du lokale Fehler protokollieren und die restlichen Zweige weiterlaufen lassen willst, setze ein inneres try-catch in jedem Zweig.
6. Fehlerbehandlung bei Abbruch von Tasks
Bei Abbruch über ein CancellationToken wird konventionell OperationCanceledException geworfen — das ist kein Fehler, sondern ein ordentlicher Stopp.
async Task DoWorkAsync(CancellationToken token)
{
for (int i = 0; i < 10; i++)
{
token.ThrowIfCancellationRequested();
await Task.Delay(100);
}
}
// Irgendwo im Code:
var cts = new CancellationTokenSource();
var task = DoWorkAsync(cts.Token);
cts.Cancel(); // Etwa nach 200 ms
try
{
await task;
}
catch (OperationCanceledException)
{
Console.WriteLine("Operation wurde abgebrochen!");
}
Neben ThrowIfCancellationRequested() werfen viele Methoden, die das Token unterstützen (z. B. Task.Delay, HttpClient.GetAsync), selbst OperationCanceledException. Prüfe, ob Abbruch unterstützt wird.
7. Nützliche Feinheiten
Ansätze für alle Fälle
- Setze try-catch auf oberster Ebene in asynchronen Methoden — so fängst du „entlaufene“ Fehler.
- Ignoriere Tasks nicht: speichere Referenzen, warte auf Abschluss und logge Fehler.
- Bei parallelen Operationen (Task.WhenAll / Parallel.ForEach) beachte AggregateException.
- Trenne Abbruch von echten Fehlern: fange OperationCanceledException separat.
- Logge Fehler, besonders „leise“ Fehler, die die Anwendung nicht zum Absturz bringen.
- Bewahre Details auf: logge alle verschachtelten Ausnahmen, nicht nur die erste.
- Behandle Fehler „vor Ort“: wenn du nicht weißt, was zu tun ist — logge zumindest.
Wo fängt man Fehler im multithreaded und asynchronen Code
flowchart TD
A[Hauptthread / UI] -->|Startet Task| B[Task/async]
B -->|Innerhalb Task| C[try/catch innerhalb der asynchronen Methode]
B -->|await im Hauptthread| D[try/catch um await]
A -->|Startet Thread| E[Thread]
E -->|Intern| F[try/catch innerhalb des Threads]
B -->|Viele Tasks| G[Task.WhenAll / Parallel.ForEach]
G -->|Fehler| H[AggregateException]
8. Typische Fehler und Stolperfallen
Fehler Nr.1: auf eine Task über .Result oder .Wait() zu warten. Deadlock und/oder unerwartete AggregateException sind möglich.
Fehler Nr.2: fire‑and‑forget ohne inneres try-catch zu starten — die Task kann leise abstürzen, es gibt keine Diagnose.
Fehler Nr.3: verpasste Fehler in parallelen Schleifen — ein Teil der Arbeit wurde nicht erledigt und du merkst es nicht.
Fehler Nr.4: kein Unterscheiden von Abbruch (OperationCanceledException) und echten Fehlern.
Fehler Nr.5: nur die erste Ausnahme unter mehreren Tasks zu loggen — die anderen bleiben „im Schatten“.
Fehler Nr.6: ein Exception-Objekt für alle Tasks wiederzuverwenden — jede Ausnahme sollte ihre eigene Instanz haben.
Fehler Nr.7: fehlende Behandlung von Ausnahmen im UI-Thread — ein Hintergrundfehler bleibt unbemerkt, die Oberfläche verhält sich „geisterhaft“.
GO TO FULL VERSION