en / de
Expertisen
Methoden
Dienstleistungen
Referenzen
Jobs & Karriere
Firma
Technologie-Trends TechCast WebCast TechBlog News Events Academy

C# Concurrency Teil 8: Delegate Tasks

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:

  1. Task mit Konstruktor kreieren und dann starten mit task.Start()
  2. Factory.StartNew() / Task.Factory.StartNew<TResult>()
  3. 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:

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:

  1. Der (Task-)State Parameter
  2. Datenübergabe als Action-Parameter
  3. 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:

  1. Synchron warten
  2. Task Continuation
  3. 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:

  1. Er läuft normal ab: ein eventueller Rückgabewert steht in der Result Eigenschaft vom Task Objekt bereit.
  2. Er wird abgebrochen.
  3. 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.

← Vorige Post
Nächster Post →
Kommentare

4 Antworten zu “C# Concurrency Teil 8: Delegate Tasks”

  1. […] repräsentiert eine CPU-bound Aufgabe die durch den Task Scheduler einem Thread zugewiesen wird. Im letzten Blog wurde gezeigt wie ein delegate Task mit TPL erstellt wird. Dieser Blog beschreibt das kontrollierte […]

  2. […] repräsentiert eine CPU-bound Aufgabe die durch den Task Scheduler einem Thread zugewiesen wird. In Teil 8 wurde gezeigt wie man einen delegate Task mit der TPL erstellt und in Teil 9 wurde das korrekte […]

  3. […] Die Methode erstellt den gRPC Client und startet einen Endlos-Thread, welcher die Verbindung kontrolliert und die Benachrichtigungen vom Server verarbeitet. Die Option LongRunning gibt dem Task Scheduler den Hinweis, dass der Thread lange läuft. Die heutige Implementation des Task Schedulers erzeugt in diesem Fall einen dedizierten Thread, statt einen Thread aus dem Threadpool zu verwenden. Siehe Blog C# Concurrency Teil 8. […]

Schreiben Sie einen Kommentar

Ihre E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Newsletter - aktuelle Angebote, exklusive Tipps und spannende Neuigkeiten

 Jetzt anmelden
NACH OBEN
Zur Webcast Übersicht