Delegate Tasks repräsentieren CPU-bound Aufgaben die durch den Task Scheduler einem Thread zugewiesen wird. Die Klasse Task wurde zusammen mit TPL (Task Parallel Library) in .NET Framework 4.0 eingeführt. In diesem Blog werden die Best Practices erklärt zum erstellen von Tasks mit TPL mit eventuellen Parametern und Rückgabewerten.
Task kreieren und starten
Wie im vorigen Blog C# Concurrency Teil 7: Die Task-Klasse schon erklärt wurde ist ein Task im Kontext von TPL eine Abstraktion einer CPU-bound Aufgabe die durch den Task Scheduler an einen Thread zugewiesen wird. Das kann ein Thread aus dem Threadpool sein oder ein eigener Thread (mit TaskCreationOptions.LongRunning).
Es gibt 3 Wege einen Task zu kreieren und zu starten:
- Task mit Konstruktor kreieren und dann starten mit task.Start()
- Factory.StartNew() / Task.Factory.StartNew<TResult>()
- Run() / Task.Run<TResult>()
Für normale Anwendungen gibt es keinen Grund einen Task mit dem Konstruktor zu erstellen und dann manuell zu starten. Dieser Weg sollte nicht verwendet werden.
Task.Factory.StartNew() hat viele Überladungen. Zusammengefasst wird folgendes ermöglicht:
- Parameterübergabe an die auszuführende Funktion (TaskState vom Typ ‚object‘),
- Verwendung eines eigenen Schedulers
- Verwendung eines CancellationTokens
- Verwendung von TaskCreationOptions
Task.Run() ist eine schlanke Variante von Task.Factory.StartNew() mit Standardwerten für die meisten Parameter.
Task.Factory.StartNew() und Task.Run() geben einen „hot“ Task zurück: einen Task der bereits gestartet ist.
Der bevorzugter Weg einen delegate Task zu kreieren und starten ist Task.Run() bzw. Task<>.Run().
Die einfachste Form einen neuen Task zu starten sieht so aus:
… Task.Run(() => StartTaskRun()); … private void StartTaskRun() { Thread.Sleep(10000); // Caution: cannot be cancelled }
Es werden keine Parameter übergeben (oder Variablen geteilt), der Task hat keinen Rückgabewert, allfällige Ausnahmen gehen verloren und der Task kann nicht abgebrochen werden. Wird die Applikation geschlossen während der Task noch am laufen ist, so wird dieser abrupt beendet.
StartNew() mit TaskCreationOptions.LongRunning
Task.Factory.StartNew() wird häufig mit der Option TaskCreationOptions.LongRunning verwendet. Damit gibt man dem TaskScheduler den Hinweis, dass der Task länger dauert. Der TaskScheduler wird daraufhin anstatt eines ThreadPool-Threads einen eigenen Thread für die Aufgabe verwenden (bei der jetzigen Implementation vom .NET Framework, siehe Abschnitt „Langläufige Aufgaben“ C# Concurrency Teil 3: Die bewährte Thread Klasse).
Langläufige Aufgaben sollten nicht auf einem Thread vom ThreadPool laufen sondern direkt einem Thread zugewiesen werden.
Parameterübergabe
Die meiste Aufgaben arbeiten mit Daten. Diese Daten können entweder geteilt werden (zwischen aufrufenden und ausführenden Tasks) oder dem Task übergeben werden. Wenn mit geteilten Daten gearbeitet wird müssen diese Daten threadsafe sein, immutable sein oder gelockt werden.
Wenn möglich, soll ein Task selbständig mit isolierten Daten arbeiten und das Resultat als Rückgabewert zurückgeben werden (Task<TResult>). Die Daten können als Parameter übergeben werden.
Es gibt verschiedene Wege Parameter zu übergeben:
- Der (Task-)State Parameter
- Datenübergabe als Action-Parameter
- Closures\Dynamic
Der (Task-)State Parameter
Es gibt viele Überladungen vom Task Konstruktor und TaskFactory.StartNew() die zur Parameterübergabe einen TaskState Parameter vom Typ „object“ haben. Man sollte aber wenn möglich Task.Run() verwenden also entfällt diese Option.
Datenübergabe als Action-Parameter
Einen Wertetyp oder Immutable (z.B. string) kann man direkt der Action als Parameter übergeben.
private void BtnStartTaskRun1Parameter_Click(object sender, RoutedEventArgs e) { string parameter = _txt1Parameter.Text; Task.Run(() => TaskRun1Parameter(parameter)); } private void TaskRun1Parameter(string parameter) { Debug.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] TaskRunParameter(): {parameter}"); }
Bei komplexen Variablen macht man am besten eine eigene Klasse.
Aber Achtung: es wird nur die Referenz übergeben und der aufrufende Thread kann gleichzeitig die Daten manipulieren (Race-Condition \ Datacorruption)!
Also muss man vorher eine Kopie der Daten machen und übergeben oder das Objekt als Immutable programmieren.
Closures\Dynamic
Als Alternative zur Implementation einer Klasse für die Parameterübergabe gibt es die Variante mit Dynamics:
private void BtnStartTaskRun2Parameter_Click(object sender, RoutedEventArgs e) { var parameter = new {Text1 = _txt2Parameter1.Text, Text2 = _txt2Parameter2.Text}; Task.Run(() => TaskRun2Parameter(parameter)); } private void TaskRun2Parameter(object parameter) { var data = (dynamic)parameter; Debug.WriteLine($"TaskRunParameter(): {data.Text1}, {data.Text2}"); }
Rückgabewerte
Ein Task<TResult> definiert ein Task mit Rückgabewert TResult.
TResult kann ein Wertetype sein oder für komplexere Datentypen eine dedizierte Klasse. Zusätzlich ist es immer möglich im Task auf gelockte, geteilte Variablen zuzugreifen.
Wenn der Task auf dem Thread fertig ist, wird der Rückgabewert im Task Objekt gespeichert oder eventuelle Ausnahmen, wenn etwas schiefgegangen ist.
Es gibt folgende Wege um auf den Task zu warten und den Rückgabewert bzw. die Ausnahmen auszuwerten:
- Synchron warten
- Task Continuation
- Wrappen in await Task<TResult>.Run()
Synchron warten
Ein einfacher aber gefährlicher Weg ist synchron zu Warten bis der Task fertig ist. Das macht man mit blockierenden Aufrufen.
Folgende Aufrufe sind blockierend und sollte nur mit sehr grosser Vorsicht verwendet werden:
- Result
- Wait()
- WaitAll(), WaitAny()
- GetAwaiter().GetResult()
Mit z.B. task.Result wird gewartet, bis der Thread fertig ist.
private void BtnStartTaskRunReturnValue_Click(object sender, RoutedEventArgs e) { Task<string> task = Task.Run(() => ReturnString()); string returnValue = task.Result; // Blocking: danger for dead-lock! _txtReturnValue.Text = returnValue; } private string ReturnString() { Task.Delay(5000, _cancellationToken).Wait(_cancellationToken); // Simulate CPU-bound work (with IO-bound call) //throw new Exception("Exception from Task."); return "Ready"; }
Dieses Warten blockiert den aufrufenden Thread. Neben dem zeitlichen einfrieren vom UI birgt dieses Konstrukt eine potentielle Gefahr für Hänger und Dead-Locks.
Hänger: kehrt ReturnString() nicht zurück, so bleibt der aufrufenden Thread für immer wartend.
Dead-Locks: dieser Fall tritt auf wenn irgendwo im Thread der SynchronisationContext oder synchrone Thread-Redirection mit z.B. Dispatcher.Invoke oder auch NotifyPropertyChanged eingesetzt wird. Damit der Thread sein Resultat zurückgeben kann auf dem UI-Thread, wird das Resultat im MessageQueue vom UI Thread gepostet. Mit Dispatcher.Invoke und dem SynchronisationContext passiert dies synchron, das heisst, der Task ist erst beendet, wenn das Posten beendet ist. Das Posten ist erst beendet, wenn der UI Thread das Item aus der MessageQueue geholt hat. Das macht der UI Thread aber erst wenn der Thread beendet ist -> Dead-Lock.
Blockierende Aufrufe wie Task.Result, Task.Wait() und Task.GetAwaiter().GetResult() soll man so gut es geht vermeiden.
GetAwaiter().GetResult()
Dieses Konstrukt gibt es erst ab .NET 4.5 und ist speziell für ‚await‘ implementiert. Es packt die AggregateException aus und wirft eine normale Ausnahme.
Man kann die Methode auch selber aufrufen um die Exception-Verarbeitung zu vereinfachen. Es löst aber immer noch nicht die Dead-Lock Gefahr.
private void BtnStartTaskGetAwaiterGetResult_Click(object sender, RoutedEventArgs e) { _txtResultGetAwaiterGetResult.Text = "In Progress"; Task<string> task = Task.Run(() => ReturnString(), _cancellationToken); try { //_cancellationTokenSource.Cancel(); _txtResultGetAwaiterGetResult.Text = task.GetAwaiter().GetResult(); } catch (OperationCanceledException) { _txtResultGetAwaiterGetResult.Text = "Cancelled"; } catch (Exception) { _txtResultGetAwaiterGetResult.Text = "Exception"; } }
Task Continuation
Ein Task hat drei Typen von Lebensenden:
- Er läuft normal ab: ein eventueller Rückgabewert steht in der Result Eigenschaft vom Task Objekt bereit.
- Er wird abgebrochen.
- Er wirft eine Ausnahme und kann nicht mehr weiter machen.
Dazu kommt noch der ewige Thread der nie endet und Hintergrund läuft um z.B. zyklische Arbeiten zu erledigen (Queues abarbeiten, Alive Signale, Überwachung, usw).
Der Zustand vom abgelaufenen Task wird in der Eigenschaft State vom Task abgebildet. Am Task kann man einen folge Task anhängen. Dieser wird gestartet abhängig vom Zustand des vorigen Tasks (der antecedent).
private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); private readonly CancellationToken _cancellationToken; public MainWindow() { InitializeComponent(); _cancellationToken = _cancellationTokenSource.Token; } private void SetReturnValueWithTaskContinuation() { SynchronizationContext synchronizationContext = SynchronizationContext.Current; Task.Run(() => ReturnString(), _cancellationToken).ContinueWith( antecedent => { if (antecedent.Status == TaskStatus.Canceled) { synchronizationContext.Post(result => _txtResult.Text = (string)result, "Cancelled"); } else if (antecedent.Status == TaskStatus.Faulted) { synchronizationContext.Post(result => _txtResult.Text = (string)result, "Exception"); } else { synchronizationContext.Post(result => _txtResult.Text = (string)result, antecedent.Result) } }); } private void BtnStartTaskRunContinueWith_Click(object sender, RoutedEventArgs e) { _txtResult.Text = "In Progress"; SetReturnValueWithTaskContinuation(); } private void BtnCancel_Click(object sender, RoutedEventArgs e) { _cancellationTokenSource.Cancel(); }
Wrappen in await Task<TResult>.Run()
Diese Lösung geht nur wenn die aufrufende Methode geprefixed werden kann mit dem statement async. Normalerweise müssen async void Methoden vermieden werden, doch mit Eventhandlern hat Microsoft eine Ausnahme gemacht: diese dürfen mit async ausgestattet werden. Best Practice ist es, eventuelle Ausnahmen abzufangen, die durch das await Statement geworfen werden, da sie sonst verloren gehen.
private async void BtnStartTaskRunAwait_Click(object sender, RoutedEventArgs e) { _txtResultAwait.Text = "In Progress"; try { _txtResultAwait.Text = await Task.Run(() => ReturnString(), _cancellationToken); } catch (OperationCanceledException) { _txtResultAwait.Text = "Cancelled"; } catch (Exception) { _txtResultAwait.Text = "Exception"; } }
Die Methode ReturnString() repräsentiert CPU-bound Arbeit und wird mit Task.Run() ausgelagert auf einem Thread. In einem späteren Blog wird gezeigt, dass Task.Run() für IO-bound Arbeiten weggelassen wird weil kein Thread involviert ist.
Follow up
Im nächsten Blog werden die Details und Best Practices vom Task Abbruch (Task Cancellation) behandelt.
Pingback: Noser Blog C# Concurrency Teil 7: Die Task-Klasse - Noser Blog
Pingback: Noser Blog C# Concurrency Teil 9: Delegate Tasks Cancellation - Noser Blog
Pingback: Noser Blog C# Concurrency Teil 10: Delegate Task Exceptions - Noser Blog
Pingback: Noser Blog » gRPC Tutorial Teil 3: Robustes Duplex-System