Asynchrone Programmierung in .NET C#

Neben den Gründen der technischen Anforderungen an eine Applikation, gibt es bereits seit Mitte der 2000er Jahre einen weiteren Grund, der immer mehr an Bedeutung gewonnen hat, nämlich die bessere Ausnutzung von modernen CPU-Architekturen (Multi-Cores) für ihre Beschleunigung. Um diese Technologien ausnutzen zu wollen, wurde dadurch das Schreiben von Multi-threaded Applikationen notwendig – also das Arbeiten mit mehreren Threads in Programmen.

Der Einsatz von Threads ist nützlich und sinnvoll, wenn lang andauernde Operationen stattfinden, die die Programmausführung blockieren (z.B. längere Berechnungen im Sekundenbereich, Warten auf bestimmte Bedingungen im System / Lauschen auf dem Netzwerk-Port usw.). Aber der Einsatz von klassischen Threads ist jedoch generell schwierig zu verstehen, denn ein bloßer Blick auf den Code reicht oft nicht - frei nach dem Motto: 1. Thread anlegen -> 2. Thread starten -> 3. Beten. wink

Die Synchronisation gemeinsam genutzter Resourcen erfordert grundsätzlich große Sorgfalt!

Der Einsatz von Threads ist beispielsweise widerum nicht sinnvoll, wenn es zu viele Abhängigkeiten zwischen den Threads gibt, denn Abhängigkeiten führen zu Blockierung von Code. In diesem Fall wartet ein Thread darauf, das ein anderer seine Arbeit beendet. Der Vorteil der asynchronen Ausführung verschwindet dann, beziehungsweise ist nicht mehr gegeben. Die Komplexität des Systems wird somit unnötig vergrößert, da viele Threads aufeinander abgestimmt werden müssen. In solchen Softwarekonstruktionen ist mitunter wochenlanges Debugging keine Seltenheit.

Dieser Umstand ist u.a. auch die Motiviation für dieses Tutorial gewesen, da ich vor nicht allzu langer Zeit bei Reviews von .NET C#-Projekten immer wieder Codeartefakte dazu fand, welche konzeptuell aus der klassischen Windows-Programmierung der 90er Jahre stammen und aus heutiger Sicht allein schon 'merkwürdig' aussehen. Mitunter kamen noch solche Konstrukte wie der Einsatz von while-Schleifen mit 'Thread.Sleep(1)', 'DoEvents()' und Ähnliches zum Einsatz, um auf die Beendigung von Vorgängen zu warten, die gerade irgendwo anders laufen.

Da das .NET-Framework seit seiner Erstveröffentlichung 2002 ordentlich gewachsen ist, sehen sich heutzutage gerade Einsteiger oder Umsteiger bereits allein mit 3 verschiedenen Implementationsmustern für die asynchrone Verarbeitung von Vorgängen konfrontiert, welche im .NET-Framework über die Jahre hinweg Einzug gehalten haben. Bevor nun auf moderne Konzepte und Hinweisen zu deren effizientem Einsatz eingegangen wird, erfolgt deshalb vorab nochmal etwas Aufklärung bezüglich der verschiedenen existierenden Features für asynchrone Programmierung unter .NET C#. Da dieser Themenkomplex ansonsten recht umfangreich ist und mittlerweile auch zahlreiche Literatur etc. dazu existiert, fokussiert sich dieses Tutorial nur auf ein paar wesentliche Punkte.

.NET 1.0 Asynchronous Programming Model (APM)

Seit den ersten Tagen von .NET 1.0 (2002) war bereits eine asynchrone Programmierung möglich. Als Entwickler musste man ein Callback übergeben, welcher dazu benutzt wurde, um die Ergebnisse der asynchronen Operation auswerten zu können. Die Art und Weise erinnerte jedoch noch stark an der klassischen Methodik z.B. aus der Programmiersprache C. Diese APIs sind in .NET leicht zu erkennen, da diese immer paarweise mit einer Begin-Funktion und einer End-Methode auftreten.

IAsyncResult BeginMethod(..., IAsyncCallback callback, object userState) { … }
EndMethod(IAsyncResult ar) { … }

In diesem Muster startet der Aufruf der Begin-Funktion den asynchronen Vorgang. Neben anderen möglichen applikationsspezifischen Parametern (siehe '...'), enthält diese Funktion jedoch immer zwei bestimmte Parameter. Beim 1. Parameter callback geht es um die Übergabe der eigens geschriebenen Callback-Methode, welche nach Beendigung des asynchronen Vorgangs aufgerufen wird. Der 2. Parameter userstate ist nützlich, um beispielsweise eine Zuordung von meheren gleichzeitig gestarteten asynchronen Vorgängen zum Callback vornehmen zu können. Hier ein kleines Beispiel:

private void ExecuteAction(object state)
{
     _ResultList.Clear();
     Console.WriteLine($"Caller thread Id: {Thread.CurrentThread.ManagedThreadId}");

     InvokerForApm invoker = _Logic.CalculatePrimes;
     // always pair-wise execution
     invoker.BeginInvoke(_ResultList, 7000000, out var usedThreadId, OnCalculationAPMCompleted, new APMState
           {
               Id = DateTime.Now,
               Logic = _Logic
           });
            
   Console.WriteLine($@"Call executed on thread {usedThreadId}");            
}
             
private void OnCalculationAPMCompleted(IAsyncResult asyncResult)
{
    var state = (APMState)asyncResult.AsyncState;
    var result = (AsyncResult)asyncResult;
    InvokerForApm caller = (InvokerForApm)result.AsyncDelegate;
               
    try
    { 
        // get result via end method
        caller.EndInvoke(out int id, asyncResult);
        Console.WriteLine($@"APM callback Thread Id: {id}");
        Console.WriteLine($@"Calculation of primes finished successfully with id {state.Id}");                
    }
    catch (Exception e)
    {
        Console.WriteLine(e);
    }                       
}

Der Start der Berechnung in dem Beispiel erfolgt über die Definition eines eigenen Delegaten InvokerForApm, welche über die Zuweisung zu der auszuführenden Methode dem Entwickler erst die Methoden Begin().. und End() zur asynchronen Ausführung zur Verfügung stellt. Darüber hinaus ist beim Aufruf der Callbackmethode OnCalculationAPMCompleted noch darauf hinzuweisen, dass beim Abholen der Ergebnisse mittels EndInvoke() auch die eventuell aufgetretenden Fehler mit abgeholt werden und zwar so, dass diese Fehler als Exception geworfen werden. Dies macht das Setzen eines try..catch-Blocks in dieser Methode auf jeden Fall erforderlich. Der Aufruf des Callbacks erfolgt im Kontext des gestarteten Background-Threads durch das .NET-Framework. Sollte also im Callback noch die GUI aktualisiert werden, muss dies über den Dispatcher oder dem jeweiligen Synchronization-Kontext erfolgen.

.NET 2.0 - 3.5 Event-based Asynchronous Pattern (EAP)

Mit dem Erscheinen des .NET-Frameworks 2.0 (2005) kam ein neues Muster hinzu. Der wesentliche Unterschied ist die Art und Weise wie Rückgabewerte übergeben werden (Events). Dies ist über lange Zeit (~7 Jahre) das Muster in der Programmierung mit .NET gewesen. Neben der eigentlichen asynchronen Methode gibt es hier immer noch einen EventHandler. Die Methode selbst dient dazu, die Operation asynchron zu starten bzw. auszuführen.

void MethodAsync(..., object userState)
event EventHandler<MethodCompletedEventArgs> MethodCompleted

class MethodCompletedEventArgs : AsyncCompletedEventArgs { … } 

Der dazu passende EventHandler, der in der Regel immer mit dem Wort "Completed" endet, wird immer dann gefeuert, um den Benutzer der API darüber zu informieren, dass die asynchrone Operation beendet wurde und es werden die Ergebnisse bzw. Fehler im Handler gleich mitgeliefert. Die Implementation der Eventklasse selbst muss immer von der Basisklasse AsyncCompletedEventArgs aus dem .NET-Framework abgeleitet sein. Diese Klasse bietet schon dem Entwickler einige Merkmalez.B. für die Fehlerbehandlung an. Hier ein weiteres kleines Beispiel:

public class TestEAP
{
	private readonly IPrimesCalculator _Logic;
   
	public TestEAP(IPrimesCalculator businessLogic)
	{
		_Logic = businessLogic ?? throw new ArgumentNullException(nameof(businessLogic));

		// observe completed event BEFORE we start calculations...
		_Logic.OnPrimesCalculationCompleted += Logic_OnPrimesCalculationCompleted;
	}
					   
	private void Logic_OnPrimesCalculationCompleted(object sender, PrimesCompletedEventArgs e)
	{
		// important: always check for error first, otherwise the .NET Framework would throw an error 
		if (e.Error == null)
		{
			Console.WriteLine($"Current thread id in completed event: {Thread.CurrentThread.ManagedThreadId}");
			Console.WriteLine($@"Result of computation: {e.ResultList.Count}");
		}
		else
		{
			Console.WriteLine($@"An error occured! {e.Error.Message}");
		}
	}

	public void Execute()
	{
		_ResultList.Clear();
		Console.WriteLine($@"Caller thread in execute action: {Thread.CurrentThread.ManagedThreadId}");

		_Logic.CalculatePrimesAsync(7000000);
		Console.WriteLine($"Ready. Calculated {_ResultList.Count} primes.");
	}
}

// ...

public class PrimesCompletedEventArgs : AsyncCompletedEventArgs
{
    private readonly IList<int> _ResultList;

    public IList<int> ResultList
    {
        get
        {
            base.RaiseExceptionIfNecessary(); // !!!
            return _ResultList;
        }            
    }

    public PrimesCompletedEventArgs(Exception error, bool cancelled, object userState) : base(error, cancelled, userState)
    {
        _ResultList = new List<int>();
    }
}

Als Benutzer eines solchen APIs mit dem EAP-Pattern, wird zunächst im Konstruktor der obigen Klasse ein entsprechendes Objekt erzeugt. Danach ist es wichtig, sich direkt beim Completed-Event anzumelden, BEVOR die spätere asynchrone Ausführung der Operation beginnt. Es kann ansonsten passieren, dass die eigentliche Ausführung des asynchronen Vorgangs schneller ist, als die Anmeldung zum Event. Nach dem der Vorgang beendet ist, wird nun der Event gefeuert. Im EventHandler Logic_OnPrimesCalculationCompleted ist es wichtig zu wissen, dass zunächst auf eventuell aufgetretende Fehler zu prüfen ist. Wird dies nicht gemacht, sondern direkt auf die Ergebnis-Eigenschaft zugegriffen, wird eine Exception geworden, welche den Abbruch des Programms zur Folge hätte. Das Ergebnis des asynchronen Vorgangs ist im Beispiel 'ResultList' und damit typsicher, da die EventArgument-Klasse ja selbst implementiert werden muss. Hier gibt es noch eine Besonderheit zubeachten. Um das EAP-Pattern gegenüber dem Benutzer der API auch durchzusetzen, muss im Getter der Ergebniseigenschaft die Methode RaiseExceptionIfNeccessary() von der zugrunde liegenden Basisklasse aufgerufen werden. Damit wird sichergestellt, dass der Entwickler des EventHandlers immer zunächst auf eventuell aufgetretende Fehler prüfen soll, um zu verhindern, dass mit ungültigen Ergebnissen weitergearbeitet wird. Sollte zum Schluss im EventHandler noch die GUI aktualisiert werden, so muss dies ebenfalls z.B. über ein DispatchCall erfolgen, da der Code noch im Kontext des gestarteten Background-Threads läuft.

Nachteile des APM-Pattern und EAP-Pattern aus .NET 1.0 und .NET 2.0

Die Nachteile der bisherigen Implementationsmuster aus den ersten Versionen des .NET-Frameworks sind im Wesentlichen, dass ein nicht unerheblicher Aufwand für die Benutzer solcher Muster in APIs betrieben werden musste. Aufgrund des paarweisen Auftretens der asynchronen Artefakte ist die Lokalität im Code nämlich nicht mehr gegeben, da man immer mindestens zwei Methoden schreiben muss. Bei APIs mit zahlreichen asynchronen Methoden und Aufrufen wird das ganz schnell unübersichtlich und die berühmt berüchtigte Callback-Hell ist dann schon in Sicht. Darüber hinaus ist hier die Fehlerbehandlung im Code als weiterer Nachteil aufzuführen. Als Entwickler eines APIs hat man speziell immer noch auf die Implementationsaspekte bei der Behandlung von Fehlern zu achten, welche ebenfalls fehleranfällig ist.

.NET 4.5 Task-based Asynchronous Pattern (TAP)

2012 kam mit dem Erscheinen des .NET-Frameworks 4.5 dann noch ein weiteres Programmiermodell hinzu. Zur selben Zeit erschien Windows 8 und seitdem gilt Microsofts neue Design Guideline Fast and Fluid. Es sprachen mehrere Gründe dafür, ein neues API zu entwickeln:

Durch die jahrelange Entwicklung hin zu mehr CPU-Cores und mehr Threads, war es sinnvoll, dem Entwickler neue Mechanismen anzubieten, mit dessen Möglichkeiten das Thema Multi-Core bzw. Multi-Threading-Entwicklung besser adressieren zu können. Des Weiteren ist durch die Entwicklung mobiler Endgeräte wie Smartphones und Tablets mit Touch-Bedienung die Akzeptanz, beziehungsweise das erwartete Verhalten an eine grafische Benutzeroberfläche permanent gestiegen. Und nicht zuletzt ging es mitunter auch um das Mantra für Programmierer, alles "einfach mal parallel" machen zu müssen oder zu wollen. Als Regel gilt, dass synchrone Methodenaufrufe, welche potentiell >50ms andauern, nun verboten sind und diese durch ihre asynchronen Varianten aus dem .NET-Framework gegebenenfalls ersetzt werden sollen! Damit sind beispielsweise sämtliche File-I/O-Zugriffe gemeint, Netzwerk-I/O, serielle Schnittstellen, Timer, Producer-/Consumer Queues (z.B. große Dateien kopieren, Firmware-Updates vom PC aus…), usw. [1]

Async / Await in der Praxis

Bei Veröffentlichung von .NET 4.5 erschien auch C# 5.0 und hier wurden nun zwei neue Schlüsselwörter eingeführt: async und await. Mit Hilfe des Schlüsselwortes async stellt der C#-Compiler sicher, das entsprechend markierte Methoden zur Kompilierzeit in deren asynchrone Varianten konvertiert werden. Dadurch wird die asynchrone Verarbeitung zu einem echten Compiler-Feature für den Entwickler. Interessant an diesen Schlüsselwörtern ist aber auch die Tatsache, dass durchaus die Gefahr besteht, die Schlüsselworte als Entwickler zunächst total falsch zu verstehen. Ein kleines Beispiel:

public class AsyncAPI
{
    async Task<Type> TestAsync() { … }
}

Dies sieht erstmal so aus, als ob diese Methoden plötzlich "einfach" asynchron aufgerufen werden. Dies ist natürlich nicht der Fall. Mit Hilfe von async legt der Compiler lediglich intern beim Compiliervorgang eine Statemachine für diese Methoden auf IL-Codeebene an, welche das "awaiten" eines oder mehrerer asynchroner Vorgänge erst zur Laufzeit des Programms ermöglicht.

Auf der Aufrufer-Seite, beziehungsweise dem Benutzer des APIs kommt also, wer hätte es gedacht, das neue Schlüsselwort await zum Einsatz. Damit wird, rein logisch betrachtet, angedeutet das man zunächst das Ergebnis des direkt hinter dem await stehenden Codes "wartet", bevor die Anweisungen nach dem await weiter verarbeitet werden. Ein kleines Beispiel:

public class AsyncUser
{
    private AsyncAPI _Provider; 
    // ...

    public async DoSomething(string argument)
    {
         Console.WriteLine("Begin..."); 
           
         await _Provider.TestAsync()

         Console.WriteLine("Ready.");
    }
}

public class AsyncAPI
{
    public async Task TestAsync()
    {
        Console.WriteLine("Starting work...");
        Task.Delay(2000);
    }    
}

Bei Aufruf der Methode DoSomething() im Beispiel erfolgt erst eine Ausgabe auf der Konsole und anschliessend soll die Methode TestAsync() hinter dem await asynchron ausgeführt werden. Es wird aber auf die Beendigung von TestAsync() gar nicht gewartet, wie man jetzt fälschlicherweise vermuten kann, sondern es wird, nachdem die Methode TestAsync() im Hintergrund zur Ausführung bereitgestellt wird und einfach 2 Sekunden wartet, der Programmkontrollfluss zunächst an den Aufrufer von DoSomething()  zurückgegeben. Erst nach dem die 2 Sekunden abgelaufen sind, kehrt der Kontrollfluss zum Code nach dem await innerhalb der Methode DoSomething() wieder zurück. Das ist der Trick bei der ganzen Geschichte hier. await hat also nicht die Aufgabe auf etwas zu warten.

Die Task Parallel Library (TPL)

Mit der Einführung der Task Parallel Library (TPL) in .NET 4.0 (2010) wurden bereits sehr gute APIs für das Task-based Asynchronous Pattern (TAP) geliefert und es war der vorbereitende Schritt, parallele Verarbeitung salonfähig zu machen. Dieses Implementationsmuster setzt eben auf dieser Bibliothek auf. Ein wesentlicher Bestandteil ist hier die Klasse System.Threading.Tasks.Task<T>.

In der realen Welt kommt in der Regel natürlich nicht Task.Delay() zum Einsatz, sondern es wird ein anderes Feature der Task-Klasse genutzt, nämlich die Methode Task.Run(). Diese Methode reiht sozusagen die auszuführende Methode bzw. den Code in den internen Task-Scheduler ein, welche dann im Hintergrund zur Ausführung vorgesehen wird. Dies unterscheidet sich etwas zu dem, wie die Task Parallel Library noch zu Zeiten von .NET 4.0 bedient wurde. Dort erfolgte das Starten von Tasks mittels Task.Factory.StartNew(….) und seinen 16 Überladungen. Die Benutzung von Task.Run(…) sollte also (fast) immer der anderen Variante vorgezogen werden!

Task.Run(…) ist also nichts anderes als: Task.Factory.StartNew( … , CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);

Wer mehr über die Hintergründe erfahren möchte, gibt es hier mehr Infos von Microsoft: Task.Run vs. Task.Factory.StartNew

Das mittels Task.Run() eine eingereihte Methode in einem seperaten Thread ausgeführt wird, ist wahrscheinlich, jedoch nicht sicher. Durch die automatische Verwaltung eines internen ThreadPools kann die TPL selbst entscheiden, ob die synchrone Methode in einem separaten Thread gestartet werden sollte. Das Anlegen eines neuen Threads ist nämlich recht teuer, beziehungsweise werden diese von der .NET-Runtime nicht voralloziert. Das Konzept von Tasks und Threads unterscheidet sich hier also etwas. In grober Näherung gilt jedoch nach wie vor das Prinzip: Ein Task und dazu ein Managed-Thread beziehungsweise Thread auf Betriebssystemebene. Letztendlich handelt es sich also bei dem Einsatz von Tasks aus Sicht des Entwicklers um eine weitere Abstraktion, welche dazu da ist, die effiziente Verwaltung parallel durchzuführender Vorgänge vom Applikationscode zu trennen. 

Und das bedeutet zudem, das asynchrone Programmierung mit Tasks nicht gleichzusetzen ist mit der klassischen Multi-threaded-Programmierung. Sie können speziell so gebaut werden können, sodass überhaupt kein bzw. kein zusätzlicher Thread aus der Sicht der .NET-CLR dafür nötig ist. In diesem Zusammenhang hat sich z.B. Stephen Cleary die Mühe gemacht, in dem er den Leser seines Tutorials speziell für "I/O-bound"-Operationen auf die Reise durch die verschiedenen Schichten des Systems mitnimmt, angefangen von der Applikation bis runter zur Hardware und wieder zurück zur Applikation. Die Erklärungen von ihm sind recht ausführlich gehalten und meiner Meinung einfach mal lesenswert. Mehr Infos auf Stephen Clearys Blog: There is no thread

Umstellung von bestehendem Code / Einsatz im UserInterface

Sobald innerhalb einer Methode ein await für eine andere Methode verwendet wird, dann wird die aufrufende Methode selbst wieder als asynchron markiert. Gehen wir davon aus, dass für das folgende Beispiel vom Task-Scheduler auch ein separater Thread für die Ausführung verwendet wird. Die Verteilung auf Threads innerhalb einer mit async markierten Methode sieht dann wie folgt aus:

public async Task DoSomething(string argument)
{
    // UI thread 
    if (string.IsNullOrEmpty(argument)) 
        throw new ArgumentNullException(nameof(argument));

    // UI thread       // Task Thread (TT)
    var result = await _Provider.TestAsync2();
    
    InvokeMethodSync(result, argument); 
}

Der Codeausschnitt zeigt eine Methode z.B. aus einer WPF-Applikation. Wie man sieht, ist die Aufteilung der Ausführung des Codes auf die zwei verschiedenen Tasks recht übersichtlich und bedarf keiner weiteren Erklärung. Die Vorteile des TAP-Patterns liegen damit quasi auf der Hand:

  • Geringe Einarbeitungszeit
  • Kein Spaghetti-Code, da sich der Code zu seinem synchronen Pendant kaum unterscheidet
  • Führt zu geringerer zyklomatischer Komplexität
  • Kein umständliches Debuggen mehr notwendig, beinhaltet auch das Exception-Handling

Faustregeln beim Einsatz von async / await

1.: Asynchron auszuführende Methoden mit async + Task<T> bis „rauf" beziehungsweise "runter“ zur .NET-API markieren, welche in mindestens einer der Schichten async und await nutzen. In diesem Zusammenhang möchte ich darauf hinweisen, dass ansonsten folgender Code in der Form tunlichst vermieden werden sollte:

//            !!!
private async void TestMethod()
{
    await Task.Run(() => 
    { 
        // do something 
    });
}

Wie man in dem Beispiel sieht, führt die Methode eine asynchrone Operation aus, beziehungsweise legt die Ausführung auf einen anderen Thread weg. Auf die Beendigung der Methode TestMethod()  kann der Aufrufer jedoch nicht mehr warten, weil die Methode mit aync void markiert wurde, anstatt mit async Task. Des Weiteren kommt es beim Werfen von Exceptions aus der asynchron ausgeführten Methode ebenfalls zu Problemen. Der Compiler warnt den Entwickler jedoch nicht vor solchen Artefakten. Deshalb ist bei der Umstellung von bestehendem Code gerade auch hier sehr sorgfältig vorzugehen. Die einzige Ausnahme sind EventHandler. Hier muss async void stehen, da sonst die Signatur für den entsprechenden Event-Delegaten nicht mehr zusammen passen würde.

2.: async + Task.Run() verwenden, um Berechnungen in UI-Applikationen elegant vom UI-Thread wegzulegen. Warum ist das aus Entwicklersicht eigentlich so einfach? Nun, dies liegt daran, dass await in einer UI-Applikation z.B. mit WinForms oder WPF den laufenden SynchronizationContext "abfängt" und darauf dann entsprechende Continuiations ausführt. Dadurch ist, im Gegensatz zum APM-Pattern oder EAP-Pattern, kein dedizierter Dispatch am UI-Thread mehr nötig. Wer mehr über SynchronizationContexts erfahren möchte, es gibt auf CodeProject ein bzw. zwei interessante Artikel dazu: Unterstanding SynchronizationContext Part I

3.: Bei der Implementierung von APIs ist bei deren Konzeption darauf zu achten, dass beim Anbieten einer synchronen UND asynchronen Variante einer Methode dessen Code redundant ausgelegt wird, da es sonst hier zu sehr ekeligen Race-Conditions kommen kann.

Einsatz auf der Seite von APIs

Der Einsatz des TAP-Pattern bietet auch Entwicklern von APIs oder im Backend Vorteile. Das Ziel ist hier, mit Hilfe der modernen Mittel, bei der asynchronen Programmierung auch dort möglichst effizient zu arbeiten. Wie bereits erwähnt, erzeugt der Compiler intern eine StateMachine, durch welche beim Einsatz der Schlüsselwörter async und await beim Aufruf einer entsprechenden Methode iteriert wird. Diese Kontextwechsel vom jeweiligen Task-Thread in den Aufrufer-Thread und zurück sind jedoch recht teuer und machen sich insbesondere dann bemerkbar, wenn beispielsweise innerhalb einer while-Schleife viele awaits durchlaufen werden. Um dieses Problem zu lösen, bietet die Klasse Task<T> die Methode ConfigureAwait() an. Diese Methode bietet einen boolschen Parameter an, mit dessen Hilfe der Aufrufer konfigurieren kann, ob ein Wechsel in den jeweiligen Aufrufer-Thread bei Beendigung des asynchron auszuführenden Codes erfolgen soll (true), oder nicht (false). Dies bedeutet also, dass der Code nach await weiterhin asynchron ausgeführt werden soll bis zum nächsten await.

Die folgende Methode kopiert eine Datei von einer Quelle zu einem Ziel. Die Daten werden über einen 4kByte großen Puffer gelesen und geschrieben. Ein Kontextwechsel soll innerhalb der Schleife vermieden werden:

public async Task CopyFileAsync(string source, string destination)
{
    var buffer = new byte[4096];
    using (var srcStream = File.OpenRead(source))
    using (var destStream = File.OpenWrite(destination))
    {
        int count;
        do
        {
            count = await srcStream.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false);
            await destStream.WriteAsync(buffer, 0, count).ConfigureAwait(false);
        } while (count > 0);
    }
}

Eine Gegenprüfung lässt sich anhand dieses Beispiels einfach und schnell durchführen. Entfernt man die ConfigureAwait(false)-Aufrufe in der while-Schleife, erhöht sich die Ausführungszeit der Methode, je nach Größe der zu kopierenden Datei, um ein Vielfaches.

Hinsichtlich des Umstiegs von bestehendem Code, welcher das APM-Pattern verwendet beziehungsweise implementiert, bietet die Task Paralllel Library unter anderem auch die Methode FromAsync() an. Mit Hilfe dieser Methode und seinen Überladungen ist es möglich, die Begin()- und End()-Methodenpärchen elegant zu einem Task zu kapseln und die Methode funktioniert mit existierenden Begin()-Methoden, welche maximal 3 Methodenparameter haben direkt aus dem Framework heraus. Ein kleines Beispiel:

public async Task<ResponseMessage> SendRequestAsync(RequestMessage message)
{
    return await Task.Factory.FromAsync(_communicator.BeginSend, _communicator.EndSend, message, null, TaskCreationOptions.None);
}

Hinsichtlich der Reduzierung und Vereinfachung der angebotenen Methoden von APIs, bietet sich die FromAsync()-Methode regelrecht an und beugt gleichzeitig etwaigen Antipatterns vor, welche bei der Umstellung bzw. Kapselung der Methodenpaare durch Tasks durchaus auftreten können. Weitere Informationen gibt es unter [2].

Synchronisierung von Tasks

Wenn mehrere Tasks bzw. Threads auf gemeinsam genutzte Resourcen zugreifen müssen, zum Beispiel auf eine serielle Kommunikationsschnittstelle, ist es oft erforderlich die Aufrufe darauf entsprechend zu synchronisieren beziehungsweise diese zu serialisieren. In klassischen multi-threaded Anwendungen kann mit dem Schlüsselwort lock { … } ein Block von Anweisungen als kritischer Abschnitt markiert werden, wenn mehrere Threads darauf zugreifen können. Dafür nutzt der Mechanismus in .NET die statische Monitor-Klasse und ein entsprechendes Synchronisationsobjekt. Hier ein kleines Beispiel:

public class CommunicationInterfaceAPI
{
    private object _locker = new object();

    public void ReadSettings() 
    {
        lock (_locker) 
        {
             // access to port...
        }
    }
}

Beim Einsatz des Duos async/await ist die Markierung mit lock { } von kritischen Abschnitten aber so nicht möglich, siehe folgendes Beispiel:

lock (_locker) 
{
     await Task.Delay(1000); 
}

Warum ist das so? Das Schlüsselwort lock { } dient lediglich zur Synchronisation von synchronem Code. Bei Anwendung des TAP-Patterns werden jedoch, wie bereits erwähnt, separat laufende Task-Objekte im eigenen Scheduler betrieben. Das C#-Compilerteam wollte durch absichtliche Nicht-Implementierung eines speziell dafür passenden Features im Framework den Entwicklern das Bewusstsein für potentielle Deadlocks klar machen. Als erste Lösung für die Synchronisation von Code ist also die Codestruktur so anzupassen, sodass die Synchronisation mit lock { } doch wieder möglich ist, siehe folgendes Beispiel:

public class CommunicationInterfaceAPI
{
    private object _locker = new object(); 

    public async Task<EResult> ReadSettingsAsync()
    {        
        return Task.Run(() => 
        {
            lock (_locker)
            {
                // access to port…
            }
            return result;
        }
    }
}

Als zweite Lösung gilt die Synchronisierung durch den Einsatz der Klasse SemaphoreSlim, welche Bestandteil der .NET CLR ist:

private static SemaphoreSlim _SlimSemaphore = new SemaphoreSlim(1, 1);
    
private async Task ReadSettingsAsync()
{
    await _SlimSemaphore.WaitAsync();

    try 
    {
        await Task.Run(() => { // access to port … }); 
    }
    catch (Exception ex) 
    {
        // do exception handling …
    }
    finally 
    {
        _SlimSemaphore.Release();
    }
}

Abbruch von Tasks

Die Task Parallel Library bietet u.a. explizite Möglichkeiten zum Abbruch von Tasks an. Dafür ist die Klasse CancellationTokenSource bzw. CancellationToken aus dem Namensraum System.Threading zuständig. Die meisten asynchronen Funktionen aus dem .NET-Framework, insbesondere die aus der TPL bieten Unterstützung für diesen Token explizit an. Die Arbeit mit diesem Token ist recht einfach gehalten, wie man anhand des folgenden Beispiels sieht:

public class TestWithCancellationToken
{
    private CancellationTokenSource _token;

    public async Task StartStop()
    {
        if (_token != null && !_token.IsCancellationRequested)
        {
            Console.WriteLine("Cancel work...");
            _token.Cancel();
            return;
        }

        Console.WriteLine("Start work...");
        _token = new CancellationTokenSource();

        await TestOperationAsync();       
    }

    private async Task TestOperationAsync()
    {
        await Task.Run(() =>
        {
            for (var i = 0; i < 5; i++)
            {
                 if (_token != null && _token.IsCancellationRequested) break;
                 // _token.ThrowIfCancellationRequested(); // Alternative

                 // execute complex work...
                 Task.Delay(1000);
            }    
         });
    }
}

Der Aufruf der Methode StartStop() startet oder stoppt die Ausführung der intern asynchron auszuführenden Methode TestOperationAsync(). Zuvor muss also erst sichergestellt werden, dass ein solcher Token bereits existiert und wird beim 1. Start nur angelegt und die Operation gestartet. Wenn zur Laufzeit von TestOperationAsync() nun wieder StartStop() aufgerufen wird, existiert nun bereits ein solcher CancellationTokenSource und es muss jetzt vom CancellationTokenSource angebotene Methode Cancel() aufgerufen werden. Dies führt anschliessend dazu, dass innerhalb der Schleife von der asynchron ausgeführten Methode zyklisch geprüft wird, ob eine Abbruchanforderung durch den Token gestellt wurde und der Task wird beendet. Es handelt sich hierbei nur um eine Möglichkeit, wie man mit CancellationToken arbeiten kann. Das Standardverhalten des Tokens arbeitet jedoch mit Exceptions (siehe Alternative). In diesem Fall muss dann entsprechender Code in der Methode StartStop() durch einen try...catch-Block eingeklammert werden, damit auch diese Abbrüche sauber behandelt werden. Zum Schluss möchte ich noch darauf hinweisen, dass der Token innherhalb von asynchronen Methoden wiederum an die internen Aufrufe von weiteren asynchronen Methoden weiter gegeben werden sollte. Mehr Informationen dazu gibt es bei Microsoft [3].

Einsatz von Async / Await auf der Konsole

Auch in .NET-Programmen, wo die Konsole zum Einsatz kommt, lässt sich das TAP-Pattern mit async / await problemlos einsetzen. Da hier jedoch, anders als in UI-Applikationen, erstmal kein entsprechender SynchronizationContext abrufbar ist, sind andere Mittel erforderlich. In .NET 4.5 ist auch ein sogenannter TaskAwaiter implementiert worden, welcher über einen Task direkt abgeholt werden kann. Für die Konsolenapplikation kann dies konkret im einfachsten Fall wie folgt aussehen:

static void Main(string[] args)
{
    //do something
    var result = MainAsync(args).GetAwaiter().GetResult();
    Console.WriteLine($"Result from Task: {result}"); 
}

static async Task<int> MainAsync(string[] args)
{
    // ...
    await DoSomethingMore();
    // ...
}

Durch den direkten Einsatz des Awaiters wird im Haupteinstigspunkt direkt das Ergebnis des Tasks von MainAsync mittels GetResult() erwartet. Da der ganze Mechanismus von async und await letztendlich fast komplett über die TaskAwaiter-Instanz im Hintergrund läuft, können alle Objekte, die diese GetAwaiter()-Funktion beinhalten, mit await auch erwartet werden.

Fazit

Wie man sieht, ist die Benuzung des mit .NET 4.5 eingeführten async/await-Features dermaßen einfach, dass man als Entwickler sofort loslegen kann. Diese Einführung war wirklich eine der coolsten Neuerungen in der Programmiersprache C# der letzten Jahre und hat ebenso Auswirkung auch auf die Weiterentwicklung anderer Programmiersprachen wie Typescript und Javascript gehabt, welche genau dieses Feature Jahre später auch implementiert haben.

Quellen

[1] Asynchrone Programmierung mit async und await, Microsoft

[2] Einschliessen von APM-Vorgängen in Tasks, Microsoft

[3] Task cancellation in managed threads, Microsoft

Diese Seite enthält Cookies. Wir verwenden Cookies, um Inhalte und Anzeigen zu personalisieren und die Zugriffe auf unsere Website zu analysieren.
Akzeptieren Ablehnen