Ein wichtiger Aspekt bei parallelen Abläufen ist die Fehlerbehandlung. Da Vorgänge gleichzeitig ausgeführt werden, können auch Ausnahmen zeitgleich auftreten. Diese sollten bei einer produktiven Anwendung sauber und zuverlässig abgefangen werden. Die TPL bietet hier einige Verbesserungen aber auch einige Fallstricke gegenüber der klassischen Multithreading-Programmierung.
In den bisherigen Posts zur Task Parallel Library (TPL) habe ich Ausnahmen völlig vernachlässigt. Das soll jetzt nachgeholt werden. Bevor ich aber auf die Besonderheiten eingehe, will ich die Handhabung von Ausnahmen bei klassischen Threads kurz ins Gedächtnis zurückrufen.
Exceptions bei der Klasse Thread
Das folgende Beispiel startet einen Hintergrund-Thread, in dem eine unbehandelte Ausnahme auftritt.
static void Main(string[] args) { Console.WriteLine("Start Main"); try { Thread t = new Thread(Do); t.IsBackground = true; t.Start(); t.Join(); } catch (Exception ex) { Console.WriteLine("Exception Main: " + ex.Message); } finally { Console.WriteLine("End Main"); Console.ReadLine(); } } static public void Do() { Console.WriteLine("Start Do"); Thread.Sleep(1000); int a = 1; a = a / --a; Console.WriteLine("End Do"); }
Es wird nicht der catch-Bereich ab Zeile 11 aufgerufen, sondern Windows zeigt einfach einen Standarddialog an und beendet das Programm:
Unbehandelte Ausnahmen in einem Hintergrund-Thread bringen den gesamten Prozess zum Absturz.
Daher empfiehlt es sich, einen Eventhandler für das Ereignis CurrentDomain.UnhandledException anzulegen. Dadurch besteht zumindest die Möglichkeit, den Anwender zu informieren und ein Logging durchzuführen.
static void Main(string[] args) { AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException); Console.WriteLine("Start Run"); try { Thread t = new Thread(Do); t.IsBackground = true; t.Start(); t.Join(); } catch (Exception ex) { Console.WriteLine("Exception Run: " + ex.Message); } finally { Console.WriteLine("End Run"); Console.ReadLine(); } } private static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) { Console.WriteLine("Unhandled Exception: " + ((Exception)e.ExceptionObject).Message); Console.ReadLine(); Environment.Exit(0); } static public void Do() { Console.WriteLine("Start Do"); Thread.Sleep(1000); int a = 1; a = a / --a; Console.WriteLine("End Do"); }
Beispiel 1 (Visual Studio 2012) auf GitHub
Der Aufruf von Enviroment.Exit(0) unterdrückt den weniger informativen Standarddialog.
Doch wie kann man eine Ausnahme aus einer Thread-Methode heraus weitergeben?
Hierzu lege ich die Thread-Methode in eine Klasse. Diese Klasse erhält eine Eigenschaft vom Typ Exception. In der Thread-Methode werden alle Ausnahmen behandelt und dieser Eigenschaft zugewiesen. Der Erzeuger des Threads kann somit abfragen, ob in der Thread-Methode eine Ausnahme aufgetreten ist oder nicht.
class Program { static void Main(string[] args) { AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException); Console.WriteLine("Start Main"); try { Thread t = new Thread(Worker.Do); t.IsBackground = true; t.Start(); t.Join(); Console.WriteLine("Work.Do Error: " + ((Worker.Error != null) ? Worker.Error.Message : "No Error")); } catch (Exception ex) { Console.WriteLine("Exception Main: " + ex.Message); } finally { Console.WriteLine("End Main"); Console.ReadLine(); } } private static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) { Console.WriteLine("Unhandled Exception: " + ((Exception)e.ExceptionObject).Message); Console.ReadLine(); Environment.Exit(0); } } static public class Worker { static public Exception Error { get; private set; } static public void Do() { Console.WriteLine("Start Work.Do"); try { Error = null; Thread.Sleep(1000); int a = 1; a = a / --a; } catch (Exception ex) { Console.WriteLine("Exception Work.Do"); Error = ex; } finally { Console.WriteLine("End Work.Do"); } } }
Beispiel 2 (Visual Studio 2012) auf GitHub
Exceptions mit der TPL
Das obige Beispiel zeigt recht gut, dass es bisher keinen Standard gab, um Ausnahmen innerhalb einer Thread-Methode nach oben weiterzugeben. Mit der TPL wurde die Handhabung von Ausnahmen deutlich vereinfacht und auch an einigen Stellen fehlertoleranter gestaltet.
Die Klasse AggregateException
Von zentraler Bedeutung ist hierbei die Klasse AggregateException. Diese ist in der Lage, mehrere Ausnahmen zusammen zufassen. Das ist notwendig, da bei der parallelen Ausführung auch mehrere Ausnahmen auftreten können.
Die Eigenschaft InnerExceptions ist eine Auflistung, die selbst wieder Objekte vom Typ AggregateException enthalten kann. Es kann somit eine Hierarchie beliebiger Tiefe aufgebaut werden. Mit der Methode Flatten() wird die Eigenschaft InnerExceptions in eine flachen Hierarchie umgewandelt, wodurch das Auslesen aller Ausnahmen vereinfacht wird.
Die Klasse kann auch außerhalb der TPL eingesetzt werden. Hierzu ein kleines Beispiel:
public void Run() { try { foreach (int result in Work(10)) Console.WriteLine("result: " + result); } catch (AggregateException aex) { Console.WriteLine("AggregateException in Run: " + aex.Message); aex.Flatten(); foreach (Exception ex in aex.InnerExceptions) Console.WriteLine(" Exception in Run: " + ex.Message); } Console.ReadLine(); } private IEnumerable<int> Work(int a) { int[] values = new int[] { 1, 0, 5, 0, 2 }; List<Exception> list = new List<Exception>(); int r; foreach (int value in values) { try { r = a / value; } catch (Exception ex) { r = -1; list.Add(ex); } yield return r; } if (list.Count > 0) throw new AggregateException(list); }
Beispiel 3 (Visual Studio 2012) auf GitHub
In der Methode Work() werden mehrere Berechnungen durchgeführt, dessen Ergebnisse die Methode durch eine Liste zurückgibt. Tritt während der Berechnung eine Ausnahme auf, so werden diese in einer lokalen Liste gesammelt. Erst am Ende der Methode wird bei Bedarf eine Aggregate-Exception erzeugt und ausgelöst.
Der Aufrufer von Work() erhält somit nur eine Ausnahme, in der aber alle aufgetretenen Ausnahmen enthalten sind.
Exceptions bei der Klasse Task
Ein entscheidender Unterschied macht sich sofort bemerkbar, sobald das erste Beispiel von der Klasse Thread auf die Klasse Task umgestellt wird.
static void Main(string[] args) { Console.WriteLine("Start Main"); try { Task task1 = Task.Factory.StartNew(Do); task1.Wait(); } catch (AggregateException aex) { Console.WriteLine("Exception Main: " + aex.Message); aex.Flatten(); foreach (Exception ex in aex.InnerExceptions) Console.WriteLine(" Exception in Main: " + ex.Message); } finally { Console.WriteLine("End Main"); Console.ReadLine(); } } static public void Do() { Console.WriteLine("Start Do"); Thread.Sleep(1000); int a = 1; a = a / --a; Console.WriteLine("End Do"); }
Beispiel 4 (Visual Studio 2012) auf GitHub
Die Ausnahme aus der Task-Methode wird an den Aufrufer weitergegeben. Hierbei handelt es sich um die oben vorgestellte Klasse System.AggregateException. In dieser ist die eigentliche Ausnahme vom Typ DivideByZeroException enthalten.
Ausgelöst wird die Ausnahme erst durch einen Zugriff auf Task.Wait(), Task.WaitAny(), Task.WaitAll(), Task.Exception oder Task<TResult>.Result. Dank dieser Arbeitsweise können Ausnahmen innerhalb einer verschachtelten Task-Kette einfach verarbeitet werden.
Es folgt ein Beispiel in dem drei Tasks angelegt werden. In der ersten Task tritt eine Division durch Null Ausnahme auf, während der zweite Task durch das Cancellation-Token beendet wird. Der dritte Task beendet sich von alleine.
public void Run() { Task task1 = null, task2 = null, task3 = null; try { CancellationTokenSource cts = new CancellationTokenSource(); CancellationToken ct = cts.Token; Console.WriteLine("Start Run\n"); task1 = Task.Factory.StartNew(Do01, ct, ct); task2 = Task.Factory.StartNew(Do02, ct, ct); task3 = Task.Factory.StartNew(Do03, ct, ct); Thread.Sleep(2000); Console.WriteLine("\nInvoke Cancel."); cts.Cancel(); Console.WriteLine("WaitAll."); Task.WaitAll(task1, task2, task3); } catch (AggregateException aex) { Console.WriteLine("\nAggregateException in Run: " + aex.Message); aex.Flatten(); foreach (Exception ex in aex.InnerExceptions) Console.WriteLine(" Exception: " + ex.Message); } finally { Console.WriteLine("\nStatus task1: " + task1.Status); Console.WriteLine("Status task2: " + task2.Status); Console.WriteLine("Status task3: " + task3.Status); Console.WriteLine("\nEnd Run"); Console.ReadLine(); } } private void Do01(Object ct) { Console.WriteLine("Start Do01"); try { Thread.Sleep(1000); // doing some work int a = 1; a = a / --a; // create an exception. } catch (Exception ex) { Console.WriteLine("\nException Do01:\n" + ex); throw; } finally { Console.WriteLine("\nEnd Do01"); } } private void Do02(Object ct) { Console.WriteLine("Start Do02"); try { for (int i = 1; i < 30; i++) { ((CancellationToken)ct).ThrowIfCancellationRequested(); Thread.Sleep(100); // doing some work } } catch (OperationCanceledException ex) { Console.WriteLine("\nOperationCanceledException Do02:\n" + ex); throw; } finally { Console.WriteLine("\nEnd Do02"); } } private void Do03(Object ct) { Console.WriteLine("Start Do03"); try { Thread.Sleep(100); } catch (Exception ex) { Console.WriteLine("\nException Do03:\n" + ex); throw; } finally { Console.WriteLine("\nEnd Do03"); } }
Beispiel 5 (Visual Studio 2012) auf GitHub
In der Ausgabe ist der Ablauf des Programms gut zu erkennen. Der Aufruf von Task.WaitAll() löst die Aggregate-Exception aus, in der die Ausnahmen von task1 und task2 enthalten sind.
Task 1:
Innerhalb der Task-Methode von task1 tritt eine Division durch Null Exception auf. Falls eine Ausnahme innerhalb der Task-Methode behandelt wird (so wie in diesem Beispiel), muss diese durch den Aufruf von throw erneut ausgelöst werden. Nur so erhält der Task am Ende den Status Faulted. Ansonsten wird der Status der Task auf RanToCompletion gesetzt.
Task 2:
Die Task-Methode von task2 prüft zyklisch den Zustand des Cancellation-Token. Ist dieses aktiv, wird durch die Methode ThrowIfCancellationRequested() die Ausnahme OperationCanceledException ausgelöst. Der Zustand der Task ist Canceled, was auf ein Beenden der Task durch ein Cancellation-Token hinweist. Damit der Status auch richtig gesetzt wird, gibt es zwei Dinge zu beachten:
1) Die Ausnahme OperationCanceledException muss mit throw weitergeleitet werden. Wird die Exception-Klasse neu instanziiert, muss im Konstruktor das Cancellation-Token übergeben werden:
throw new OperationCanceledException("Message", ex, (CancellationToken)ct);
2) Beim Erzeugen der Task-Instanz muss ebenfalls das Cancellation-Token angegeben werden (Zeile 10). In diesem Beispiel ist es der dritte Parameter der Methode StartNew().
task2 = Task.Factory.StartNew(Do02, ct, ct);
Task 3:
In der Task-Methode von task3 wird keine Ausnahme ausgelöst. Nach ca. 100ms beendet sich der Task selbst. Der Status der Task ist RanToCompletion.
Exceptions bei Parallel.Invoke()
Das Verhalten der Methode Parallel.Invoke() ist vergleichbar mit der Klasse Task. Ausnahmen, die in den jeweiligen Thread-Methoden abgefangen werden, sind per throw weiterzuleiten. Nur so erhält der Aufrufer eine Ausnahme vom Typ AggregateException.
Über den ersten Parameter der Methode Parallel.Invoke() kann ein Cancellation-Token übergeben werden. Wird dieses vor dem Aufruf von Parallel.Invoke() gesetzt, so erhält der Aufrufer eine Ausnahme vom Typ OperationCanceledException und die einzelnen Aktionen werden erst gar nicht gestartet.
public void Run() { try { CancellationTokenSource cts = new CancellationTokenSource(); ParallelOptions po = new ParallelOptions(); po.CancellationToken = cts.Token; Console.WriteLine("\nStart Run"); Task t = Task.Run(() => DoCancel(cts), cts.Token); Console.WriteLine("\nParallel.Invoke"); Parallel.Invoke(po, () => Do01(po.CancellationToken), () => Do02(po.CancellationToken), () => Do03(po.CancellationToken)); } catch (AggregateException aex) { Console.WriteLine("\nAggregateException in Run: " + aex.Message); aex.Flatten(); foreach (Exception ex in aex.InnerExceptions) Console.WriteLine(" Exception: " + ex.Message); } catch (OperationCanceledException ex) { Console.WriteLine("\nOperationCanceledException in Run:\n" + ex.Message); } finally { Console.WriteLine("\nEnd Run"); Console.ReadLine(); } } private void DoCancel(CancellationTokenSource cts) { // cts.Cancel(); // this will invoke the OperationCanceledException in Run() Console.WriteLine("Cancel in 2sec"); cts.CancelAfter(2000); } private void Do01(CancellationToken ct) { Console.WriteLine("Start Do01"); try { Thread.Sleep(1000); // doing some work int a = 1; a = a / --a; // create an exception. } catch (Exception ex) { Console.WriteLine("\nException Do01:\n" + ex); throw; } finally { Console.WriteLine("\nEnd Do01"); } } private void Do02(CancellationToken ct) { Console.WriteLine("Start Do02"); try { for (int i = 1; i < 30; i++) { ct.ThrowIfCancellationRequested(); Thread.Sleep(100); // doing some work } } catch (Exception ex) { Console.WriteLine("\nException Do02:\n" + ex); throw; } finally { Console.WriteLine("\nEnd Do02"); } } private void Do03(CancellationToken ct) { Console.WriteLine("Start Do03"); try { Thread.Sleep(100); } catch (Exception ex) { Console.WriteLine("\nException Do03:\n" + ex); throw; } finally { Console.WriteLine("\nEnd Do03"); } }
Beispiel 6 (Visual Studio 2012) auf GitHub
Parallel.Invoke() wird synchron ausgeführt; erst wenn alle drei Thread-Methoden beendet sind, wird die Ausführung des Aufrufers fortgeführt. Somit sind keine weiteren Maßnahmen zur Synchronisierung notwendig.
Exceptions bei Parallel.For() und Parallel.ForEach()
Parallel.For() und Parallel.ForEach() teilen die einzelnen Schleifendurchläufe je nach Bedarf in mehrere Threads auf. Innerhalb dieser Thread-Methoden kann es zu Ausnahmen kommen, die gesammelt und gemeinsam als AggregateException an den Aufrufer weiter gegeben werden.
Auch lassen sich die Methoden Parallel.For() und Parallel.ForEach() durch ein Cancellation-Token vorzeitig beenden. Beide Fälle zeigt das folgende Beispiel:
public void Run() { ParallelLoopResult loopResult = new ParallelLoopResult(); try { CancellationTokenSource cts = new CancellationTokenSource(); ParallelOptions po = new ParallelOptions(); po.CancellationToken = cts.Token; Console.WriteLine("\nStart Run"); Task t = Task.Run(() => DoCancel(cts), cts.Token); Console.WriteLine("\nParallel.For"); loopResult = Parallel.For(0, 50000, po, (index, loopState) => { DoWork(index, loopState); }); } catch (AggregateException aex) { Console.WriteLine("\nAggregateException in Run: " + aex.Message); aex.Flatten(); foreach (Exception ex in aex.InnerExceptions) Console.WriteLine(" Exception: " + ex.Message); } catch (OperationCanceledException ex) { Console.WriteLine("\nOperationCanceledException in Run:\n" + ex.Message); } finally { Console.WriteLine("\nloopResult.IsCompleted: " + loopResult.IsCompleted); Console.WriteLine("\nEnd Run"); Console.ReadLine(); } } private void DoCancel(CancellationTokenSource cts) { // cts.Cancel(); // this will invoke the OperationCanceledException in Run() Console.WriteLine("Cancel in 2sec"); cts.CancelAfter(2000); } private void DoWork(int index, ParallelLoopState loopState) { double temp = 1.1; try { // create an exception. if (index == 25000) { int a = 1; a = a / --a; } // doing some work for (int i = 0; i < 5000; i++) { temp = Math.Sin(index) + Math.Sqrt(index) * Math.Pow(index, 3.1415) + temp; if (loopState.ShouldExitCurrentIteration) return; } } catch (Exception ex) { Console.WriteLine("\nException DoWork:\n" + ex); throw; } }
Beispiel 7 (Visual Studio 2012) auf GitHub
Die Methode DoCancel() wird asynchron gestartet (Zeile 10) und setzt nach ca. 2 sec das Cancellation-Token (Zeile 39).
Sobald in einer Thread-Methode eine Ausnahme auftritt, kann es sinnvoll sein, auch die anderen Thread-Methoden zu beenden. In Zeile 57 fragt jede Thread-Methode ab, ob eine Ausnahme in irgend einer anderen Thread-Methode auftrat. Ist dieses der Fall, beendet sich die Thread-Methode selber. Die Eigenschaft ParallelLoopResult.IsCompleted teilt dem Aufrufer mit, ob die Schleife erfolgreich und vollständig ausgeführt wurde (Zeile 30).
Werden in den Thread-Methoden die Ausnahmen behandelt, so sollten diese, wie bei der TPL üblich, per throw weitergeleitet werden. Der Aufrufer erhält dadurch eine Ausnahme vom Typ AggregateException.
fehlerhaftes Verhalten?
Leider stimmt das oben beschriebene Verhalten nicht so ganz. In einem Beispiel habe ich versucht mehrere Ausnahmen in unterschiedlichen Thread-Methoden zu erzeugen. Erwartet habe ich eine Ausnahme vom Typ AggregateException in der alle erzeugten Ausnahmen enthalten sind. Das war leider nicht der Fall. Es schien so, als wenn die erste Ausnahme die gesamte Methode beenden würde. Per Debugger habe ich sicher gestellt, dass die Schleife durch mehrere Threads bearbeitet wird. Aus meiner Sicht ist die TPL an dieser Stelle noch fehlerhaft (getestet unter .NET Framework V4.5.50709).
Für Kommentare, die dieses Verhalten erklären oder bestätigen, wäre ich sehr dankbar.
ParallelLoopResult loopResult = new ParallelLoopResult(); try { Console.WriteLine("\nParallel.For Start"); loopResult = Parallel.For(0, 50000, (index) => { if (index == 5000) throw new IndexOutOfRangeException(); if (index == 49999) throw new DivideByZeroException(); double temp = 1.1; // doing some work for (int i = 0; i < 1000; i++) { temp = Math.Sin(index) + Math.Sqrt(index) * Math.Pow(index, 3.1415) + temp / Math.Cosh(i + index * 0.73); } }); Console.WriteLine("\nParallel.For End"); } catch (AggregateException aex) { Console.WriteLine("\nAggregateException in Run: " + aex.Message); aex.Flatten(); foreach (Exception ex in aex.InnerExceptions) Console.WriteLine(" Exception: " + ex.Message); } finally { Console.WriteLine("\nloopResult.IsCompleted: " + loopResult.IsCompleted); Console.WriteLine("\nEnd Run"); Console.ReadLine(); }
Beispiel 8 (Visual Studio 2012) auf GitHub
Exceptions bei Workflows
Auch bei Workflows, die per Task.ContinueWith() angelegt werden, hilft die Klasse AggregateException bei der Fehlerbehandlung. Alle Ausnahmen, die in den Methoden auftreten, werden in einer Aggregate-Exception gesammelt.
Ausgelöst wird die Ausnahme erst durch einen Zugriff auf Task.Wait(), Task.WaitAny(), Task.WaitAll(), Task.Exception oder Task<TResult>.Result.
Mit der Eigenschaft Task.Exception kann auch festgestellt werden, ob die Vorgängertask eine Ausnahme geworfen hat.
public void Run() { Task task1 = null; Task task2 = null; Task task3 = null; try { Console.WriteLine("Start Run"); task1 = Task.Run(() => DoWork01()); task2 = task1.ContinueWith(task => DoWork02(task)); task3 = task2.ContinueWith(task => DoWork03(task)); Task.WaitAll(task1, task2, task3); } catch (AggregateException aex) { Console.WriteLine("\nAggregateException in Run: " + aex.Message); aex.Flatten(); foreach (Exception ex in aex.InnerExceptions) Console.WriteLine(" Exception: " + ex.Message); } finally { Console.WriteLine("\ntask1.Status: " + task1.Status); Console.WriteLine("task2.Status: " + task2.Status); Console.WriteLine("task3.Status: " + task3.Status); Console.WriteLine("End Run"); Console.ReadLine(); } } private void DoWork01() { Console.WriteLine("Start DoWork01"); throw new DivideByZeroException(); } private void DoWork02(Task task) { Console.WriteLine("Start DoWork02"); if (task.Exception != null) { Console.WriteLine("\nAggregateException in DoWork02: " + task.Exception.Message); task.Exception.Flatten(); foreach (Exception ex in task.Exception.InnerExceptions) Console.WriteLine(" Exception: " + ex.Message); } } private void DoWork03(Task task) { Console.WriteLine("Start DoWork03"); throw new IndexOutOfRangeException(); }
Beispiel 9 (Visual Studio 2012) auf GitHub
Globales Exception Handling
Von der Klasse Task erzeugte Ausnahmen lassen sich mit verschiedenen Methoden (wie z.B. Task.Wait(), Task.Result, Task.Exception, …) ermitteln.
Werden diese Ausnahmen nicht behandelt, so löst die Finalizer-Methode des Tasks das Ereignis TaskScheduler.UnobservedTaskException aus. Werden auch dort die Ausnahmen nicht behandelt, so wird der Prozess unter .NET 4.0 mit einer Fehlermeldung beendet.
.NET 4.5 ist hier deutlich fehlertoleranter und ignoriert solche Ausnahmen. Das kann für einen Entwickler recht unangenehm werden, wenn unter .NET 4.5 getestet, aber beim Kunden .NET 4.0 installiert wurde. Mit der folgenden Einstellung in der App.config erhält .NET 4.5 das gleiche Verhalten wie .NET 4.0:
<runtime> <ThrowUnobservedTaskExceptions enabled="true"/> </runtime>
Das folgende Beispiel zeigt, wie bei der Verwendung der TPL unbehandelte Ereignisse abgefangen werden.
static void Main(string[] args) { Console.WriteLine("Start"); TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException; Task.Run(() => { throw new InvalidOperationException(); }); Thread.Sleep(100); GC.Collect(); GC.WaitForPendingFinalizers(); } private static void TaskScheduler_UnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e) { Console.WriteLine("Unobserved Task Exception: " + e.Exception.Message); e.SetObserved(); e.Exception.Flatten(); foreach (Exception ex in e.Exception.InnerExceptions) Console.WriteLine(" Exception: " + ex.Message); }
Beispiel 10 (Visual Studio 2012) auf GitHub
Wichtig ist der Aufruf der Methode UnobservedTaskExceptionEventArgs.SetObserved(). Dadurch wird die Ausnahme als behandelt markiert und der Prozess wird nicht vorzeitig beendet.
Fazit
Die oben aufgeführten Beispiele zeigen recht deutlich, dass die Handhabung von Ausnahmen mit der Task Parallel Library klarer strukturiert ist, als mit der Klasse Thread. Endlich können Ausnahmen aus einer Task-Methode direkt im Aufrufer behandelt werden. Selbstgestrickte Hilfsmittel, wie im ersten Beispiel, gehören somit der Vergangenheit an.