Tasks und async/await
Ein Prozess hat ein oder mehrere Threads. Das Betriebssystem plant und verteilt Threads auf verfügbare CPU-Kerne. Wenn der Thread im Wartemodus oder blockiert ist, dann kann ein anderer Thread diesen CPU-Kern verwenden.
Das Erzeugen und Löschen von Threads ist recht aufwendig. Daher hat .NET einen Thread-Pool, aus dem sich die Anwendung aus einem begrenzten Pool an Threads einen Thread „leihen“ kann. Wird der Thread nicht mehr benötigt, so geht er wieder zurück in den Pool und steht wieder zur Verfügung.
So viel zur groben Theorie. Was ist nun aber ein Task und wieso verwenden wir nicht direkt Threads?
Was ist ein Task?
Ein Task ist an und für sich eine recht einfache Klasse, die ein paar Eigenschaften und Methoden besitzt. Hier ein Auszug:
- IsCompleted: gibt zurück, ob der Task fertig ist
- IsFaulted: gibt zurück, ob der Task bei der Ausführung eines Exception geworfen hat
- Result: gibt das Ergebnis zurück. Falls der Task noch läuft, wird hiermit der aktuelle Thread blockiert. Sollte nur verwendet werden, wenn garantiert ist, dass der Task beendet ist.
- Status: gibt den aktuellen Status des Task zurück
- ConfigureAwait(): Steuerung, ob der nachfolgende Code bei Verwendung von async/await in dem Thread ausgeführt werden soll, in dem der Code gestartet wurde. Funktioniert nur, wenn dieser Thread einen Synchronization-Context besitzt (bspw. bei WinForms).
- ContinueWith(): ermöglicht die Übergabe eines Delegate, der ausgeführt werden soll, wenn der Task fertig ist.
- WaitAsync(): falls die Methode zum Start des Tasks kein Cancellation-Token unterstützt, so kann dies hier ergänzt werden
- Wait(): blockiert den aktuellen Thread, bis er fertig ist. Sollte möglichst nicht verwendet werden.
Dann gibt es noch ein paar statische Hilfsmethoden, die ein Task bereitstellt:
- Run(): Gibt den Task weiter an den TaskScheduler, damit dieser diesen zur weiteren Verarbeitung einreiht (normalerweise unter Verwendung des Thread-Pools). Falls es sich um einen Long-Running Task handelt, wird ein eigener Thread dafür erzeugt, um den Thread-Pool nicht zu blockieren.
- Delay(): Erzeugt einen Timer und sobald dieser fertig ist, wird der Task auf fertig gestellt.
- FromResult(): liefert einen fertigen Task inkl. Ergebnis zurück.
Für uns Entwickler bietet ein Task eine nette und recht einfache Abstraktion von Threads.
Ablauf Task.Run()
Hier eine sehr gekürzte Zusammenfassung, was bei einem Task.Run() stattfindet.
- Eine Instanz von Task wird erstellt.
- Das Task-Objekt wird an den Task-Scheduler übergeben
- Der Task-Scheduler startet den Task unter Verwendung des Thread-Pools
- Der Delegate, der in Task.Run angegeben wurde, wird ausgeführt
- Falls Aktionen mit ContinueWith() definiert wurden, werden diese jetzt ausgeführt. All diese Aktionen werden intern wieder in neue Tasks verpackt.
async/await
async/await steht mittlerweile in einigen Programmiersprachen zur Verfügung.
Anstatt den Folgecode, wie weiter oben beschrieben, mit ContinueWith() anzugeben, kann hier der Code beinahe so geschrieben werden, als ob es sich um synchronen Code handelt.
Bei Methoden, die das „async“-Schlüsselwort besitzen, erzeugt .NET im Hintergrund eine State-Machine, die ähnlich wie ein IEnumerable funktioniert. Sobald ein Task fertig ist, auf den mittels „await“ gewartet wird, springt der Code weiter.
Eine Spezialität kommt dann ins Spiel, wenn der Thread, in dem der Task gestartet wurde, einen Sychronization-Context besitzt. Dies ist bspw. bei WinForms im UI-Thread der Fall. Wird ohne Angabe von „ConfigureAwait(false)“ auf das Ergebnis des Task gewartet, dann wird der Folgecode ebenfalls wieder in dem Thread weitergeführt, der den Task gestartet hat. Somit ist es möglich, die Änderungen an der UI zu machen, ohne dass ich als Entwickler explizit wieder in den UI-Thread wechseln muss. Dies passiert, indem auf dem Synchronization-Context die post()-Methode aufgerufen wird.
Wird ConfigureAwait(false) angegeben, dann wird der Folgecode wieder in den Thread-Pool eingereiht und läuft in irgendeinem Thread weiter.
Grundsätzlich sollte ConfigureAwait(false) immer verwendet werden, da dies besser für die Performance ist, da auf keinen speziellen Thread gewartet werden muss, und die Gefahr von Dead-Locks geringer ist (dazu mehr weiter unten).
Locking mit async/await
Das Schlüsselwort „lock“ wird in Kombination mit „await“ nicht unterstützt. Stattdessen muss bspw. auf den SemaphoreSlim zurückgegriffen werden.
var semaphore = new SemaphoreSlim(1, 1);
await semaphore.WaitAsync(cancellationToken);
try
{
await ExecuteAsync(cancellationToken);
}
finally
{
semaphore.Release();
}
CancellationToken
Zu den best practises gehört, dass beim Aufruf von Async-Methoden immer ein Cancellation-Token übergeben werden sollte. Ein Cancellation-Token erlaubt es, einen asynchronen Vorgang sauber abzubrechen, indem der ausführende Code darüber informiert wird und dieser dann den Abbruch geordnet durchführt.
In ASP.NET Core wird der CancellationToken bei einem Request immer übergeben, falls ein Parameter dafür vorhanden ist. Zur manuellen Erstellung eines CancellationTokens wird die CancellationTokenSource verwendet:
using var cancellationTokenSource = new CancellationTokenSource();
var cancellationToken = cancellationTokenSource.Token;
Die CancellationTokenSource hat auch eine Methode, um den „Cancel“-Vorgang zu initiieren:
cancellationTokenSource.Cancel();
Wenn ich Entwickler einer Async-Methode bin, dann gibt es mehrere Möglichkeiten, wie ich auf ein Cancel reagieren kann. Zum einen hat der Token eine Eigenschaft IsCancellationRequested. Alternativ kann die Methode ThrowIfCancellationRequested() aufrufen. Hiermit wird eine OperationCanceledException aufgerufen, wenn IsCancellationRequested gleich true ist.
Manchmal gibt es auch den Fall, dass meine Methode einen CancellationToken bekommt, den ich an eine andere Methode weitergebe. Allerdings soll es möglich sein, dass ich diesen Token auch selbst canceln kann. Da meine Methode nicht Besitzer der CancellationTokenSource ist, ist dies nicht ohne weiteres möglich. Hierfür wird eine verlinkte TokenSource benötigt:
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Diese hat ebenfalls wieder einen CancellationToken, der den Cancel-Status bekommt, wenn entweder der eingehende Token den Cancel-Status bekommt oder der Cancel-Status mittels Cancel() auf der verlinkten CancellationTokenSource aufgerufen wird 🤪.
TaskCompletionSource
TaskCompletionSource sind dann praktisch, wenn mit asynchronen Operationen gearbeitet wird, die keinen Task zurückgeben. Damit kann ein Task „von außen“ gesteuert werden.
Eine Instanz dieser Klasse hat eine Eigenschaft Task, sowie Methoden wie SetResult() oder SetException().
Beispiele für Dead-Locks
Beispiel 1
Man nehme eine einfache WinForms-Anwendung.
private void button1_Click(object sender, EventArgs e)
{
DoSomethingAsync().Wait();
}
private async Task DoSomethingAsync()
{
await Task.Delay(2000);
Text = "OK";
}
Die schnellste Möglichkeit, in WinForms einen Dead-Lock zu produzieren 😝.
Da nach Beendigung von Task.Delay(2000) ein Wechseln zurück in den UI-Thread gemacht werden sollte, dieser aber durch Wait() blockiert ist, geht gar nichts mehr.