Async & Await in Depth 🏅
Async and Await in C#
Die Schlüsselwörter async und await sollten mittlerweiler jedem .NET Programmierer bekannt sein. Viele von uns nutzen sie regelmäßig bei ihrer täglichen Arbeit und doch scheint vielen unklar zu sein, was genau bei der Verwendung dieser beiden Anweisungen passiert.
In diesem Beitrag wollen wir tief eintauchen in das async und await Konstrukt und dabei der Magie hinter dem Ganzen auf den Grund gehen.
Synchron, Asynchron, Multithreaded
Klären wir zunächst einmal die Frage, was asynchron überhaupt bedeutet? Für die meisten Programmierer heisst es einfach, dass ein neuer Thread gestartet wird und so Teile des Programms parallel ausgeführt werden. Doch asynchron heißt nicht unbedingt, dass das Programm auf mehrere Threads aufgeteilt werden muss.
Dazu sollten wir uns die Unterschiede zwischen den Begriffen Synchron, Asynchron und Multithreaded klar machen. Werfen wir zu diesem Zweck einen Blick in die Küche unserer Firma am frühen Morgen. Wie alle Softwareentwickler brauchen auch meine Arbeitskollegen morgens erstmal einen starken Kaffee und da heute ein besonderer Tag im Büro ist, stehen auch noch Brötchen bereit.
Kollege A betritt die Küche, geht zur Kaffeemaschine, stellt seine Tasse darunter, wartet dort bis der Kaffee fertig ist und schmiert sich anschließend ein Brötchen. Er arbeitet hier ganz klar synchron.
Kollege B betritt nach ihm die Küche, stellt seine Tasse ebenfalls unter die Kaffeemaschine und startet sie. Während die Maschine arbeitet, schmiert er sich sein Brötchen. Nachdem er fertig ist, nimmt er sich die Kaffeetasse und geht. Dies ist asynchrones arbeiten. Wenn die Kaffeemaschine noch läuft, nachdem er die Brötchen schon fertig hat, wartet er einfach noch kurz bis sie fertig ist.
Als letztes betreten die Kollegen C und D den Raum. Der Kollege C macht zwei Tassen Kaffee fertig, während der Kollege D zwei Brötchen schmiert. Es gibt also für jede Aufgabe genau einen Mitarbeiter, der diese bearbeitet. Dies entspricht dem Multithreading-Ansatz unserer Programmiersprache.
Wir sehen also, Multithreading ist nur eine Art von Asynchronität. Bei der Verwendung von Multithreading innerhalb unserer Programmlogik benutzen wir sogenannte Worker, die eine bestimmte, prozessor intensive Aufgabe abarbeiten. Bei der asynchronen Programmierung, z.B. mit async und await, geht es um sogenannte Tasks, die eine bestimmte Aufgabe bearbeiten und auf die gewartet werden kann.
Der Task muss dabei nicht unbedingt einen neuen Thread starten, es gibt auch andere Möglichkeiten, Asynchronität zu erlauben. Stepen Cleary beschreibt in seinen Blogpost unter blog.stephencleary.com, dass es bei asynchroner Programmierung nicht unbedingt um Threads gehen muss.
In seinem Beispiel entwickelt er eine Dateisystemoperation, die in ihrer asynchronen Methode eine Funktion des Betriebssystems aufruft. Das Betriebssystem erlaubt der Hardware, die Operation auszuführen und wartet auf ein Interrupt. Letztendlich erlaubt dieses Interrupt, den Task abzuschließen und im Programmfluss weiter zu gehen.
Dies ist eine stark vereinfachte Zusammenfassung des oben genannten Beitrages. Wer sich dies genauer durchlesen möchte, sollte sich den Beitrag auf jeden Fall anschauen.
Läuft bei diesem Beispiel nun wirklich alles, ohne einen Thread zu verwenden?
Nicht ganz. Es werden schon Threads verwendet, diese werden aber nur für wenige Millisekunden vom System ausgeliehen und danach sofort wieder zurückgegeben.
Das bedeutet aber nicht, dass wir in der asynchronen Programmierung ganz ohne Threads auskommen.
Wenn wir selber einen neuen Thread starten möchten, müssen wir in der letzten unserer asynchronen Methoden die Task.Run() Methode aufrufen. Dies zwingt das .Net Framework dazu einen neuen Thread zu erzeugen. Die Operation, die innerhalb dieser Methode ausgeführt wird, wir auf einen neuen Thread ausgeführt.
Beispiel
private async void Button_Click(object sender, RoutedEventArgs e)
{
this.WriteLine(
$"Starting on: {System.Threading.Thread.CurrentThread.ManagedThreadId}");
await this.WaitAsync();
this.WriteLine(
$"Return to main on:{System.Threading.Thread.CurrentThread.ManagedThreadId}");
}
private void Wait()
{
this.WriteLine(
$"Entering Wait on:{System.Threading.Thread.CurrentThread.ManagedThreadId}");
System.Threading.Thread.Sleep(5000);
this.WriteLine(
$"Leaving Wait on:{System.Threading.Thread.CurrentThread.ManagedThreadId}");
}
private async Task WaitAsync()
{
this.WriteLine(
$"Entering WaitAsync on:{System.Threading.Thread.CurrentThread.ManagedThreadId}");
await Task.Run(() => this.Wait());
this.WriteLine(
$"Leaving WaitAsync on:{System.Threading.Thread.CurrentThread.ManagedThreadId}");
}
Ausgabe:
Starting on: 9
Entering WaitAsync on: 9
Entering Wait on: 10
Leaving Wait on: 10
Leaving WaitAsync on: 9
Return to main on: 9
In der Ausgabe sehen wir, dass wir auf Thread Nr. 9 starten, mit diesem Thread betreten wir auch noch die WaitAsync() Methode und erst die Wait() Methode, die innerhalb des Task.Run() abläuft, benutzt einen neuen Thread. Auf diesem Thread verlassen wir auch die Methode. In der WaitAsync() Methode sind wir allerdings wieder zurück auf den ursprünglichen Thread. Wie kann das funktionieren?
Ganz einfach. Das .Net Framework merkt sich den Context, und damit auch den Thread, von dem eine Async-Methode aufgerufen wird, und stellt diesen beim Verlassen wieder her. Das hat den Vorteil, dass wir uns nicht mehr darum kümmern müssen, wie wir auf den ursprünglichen Thread zurückkommen. Zum Beispiel wenn wir vom UI Thread kommen und nach dem asynchronen Aufruf unseren View updaten möchten, macht uns dies das Leben deutlich einfacher. Der Nachteil dabei ist, dass wir ungewollte Deadlocks erstellen können. Dies schauen wir uns im unter Best Practies noch einmal genau an.
Was passiert nun im Hintergrund?
Tauchen wir an dieser Stelle zuerst einmal tief in das Framework ein und schauen uns an, was passiert wenn der Compiler auf eine asynchrone Methode trifft.
Dazu sollten wir uns zuerst einmal klarmachen, dass async und await sogenannte Compiler Anweisungen sind. Dies bedeutet, dass der Compiler den Code auf eine bestimmte Art und Weise umbaut, wenn er auf eines dieser Schlüsselwörter trifft. In dem kompilierten Code, der sogenannten Intermediate Language, tauchen diese beiden Wörter nicht mehr auf.
Schauen wir uns an, was der Compiler aus der folgenden Klasse macht.
public class MyClass
{
public async Task<int> DoSomethingAsync(int parameter)
{
var result = await MyMethodAsync(parameter);
return result;
}
}
Wenn der Compiler auf unsere asynchrone Methode trifft, wird er diese zu einem Konstrukt umbauen, das dem folgenden Code Schnipsel ähnlich sieht.
[DebuggerStepThrough]
[AsyncStateMachine(typeof(MethodAsyncStateMachine))]
public Task<int> DoSomethingAsync(int parameter)
{
MethodAsyncStateMachine methodAsyncStateMachine = new MethodAsyncStateMachine()
{
__parameter = parameter,
__this = this,
__builder = AsyncTaskMethodBuilder<int>.Create(),
__state = -1
};
methodAsyncStateMachine.Builder.Start(ref methodAsyncStateMachine);
return methodAsyncStateMachine.Builder.Task;
}
Der Code ist hier und in den folgenden Abschnitten etwas vereinfacht dargestellt, um die Lesbarkeit zu erhöhen. Wer aber eine Assembly mit einem asynchronen Aufruf einmal dekompiliert, sollte das Konstrukt dort wieder erkennen.
Wir sehen hier, dass der Compiler unseren asynchronen Methodenaufruf komplett umgebaut hat und hier eine Statemachine startet.
Dieser State Machine werden alle lokalen Variablen der Methode als Felder zugewiesen. Sie enthält auch einen Verweis auf unsere aktuelle Klasse, um auf Klassenvariablen und Methoden zugreifen zu können. Außerdem wird der Status der State Machine auf -1 gesetzt, was der initiale Status der State Machine ist und anzeigt das diese noch nicht gestartet wurde.
Schauen wir uns als nächstes an, wie diese State Machine intern aufgebaut ist.
public sealed class MethodAsyncStateMachine : IAsyncStateMachine
{
public AsyncTaskMethodBuilder<int> __builder;
public int __state;
public MyClass __this;
public int __parameter;
private TaskAwaiter<int> __taskAwaiter;
private int __result;
void IAsyncStateMachine.MoveNext()
{
int result2;
try
{
TaskAwaiter<int> taskAwaiter;
if (this.__state != 0)
{
taskAwaiter = this.__this.MyMethodAsync(this.__state).GetAwaiter();
if (!taskAwaiter.IsCompleted)
{
this.__state = 0;
this.__taskAwaiter = taskAwaiter;
MethodAsyncStateMachine stateMachine = this;
this.__builder.AwaitUnsafeOnCompleted<TaskAwaiter<int>, MethodAsyncStateMachine>(
ref taskAwaiter, ref stateMachine);
return;
}
}
else
{
taskAwaiter = this.__taskAwaiter;
this.__taskAwaiter = default(TaskAwaiter<int>);
this.__state = -1;
}
int result = taskAwaiter.GetResult();
taskAwaiter = default(TaskAwaiter<int>);
this.__result2 = result;
result2 = this.__result;
this.__state = -2;
this.__builder.SetResult(result2);
}
catch (Exception exception)
{
this.__state = -2;
this.__builder.SetException(exception);
}
}
}
Auch hier habe ich den Code wieder vereinfacht dargestellt, um die Lesbarkeit zu erhöhen. Wir sehen die MoveNext Methode und die Felder, welche in der vorigen Methode verwendet wurden.
public int __state;
public MyClass __this;
public int __parameter;
void IAsyncStateMachine.MoveNext() { ... }
Nicht jede Zeile innerhalb der Methode muss von uns direkt verstanden werden, aber wir sollten uns die interessanten Stellen anschauen. Beim Einstieg in die Methode sehen wir, dass in der ersten if Abfrage der aktuelle Status auf ungleich 0 geprüft wird. Wir erinnern uns, dass wir die StateMachine mit dem Status -1 initialisiert haben. Das bedeutet, dass wir beim ersten Durchlaufen auf jeden Fall in diesem Block landen.
Als erste Anweisung wird hier unsere Asynchrone MyMethodAsync aufgerufen, der vorher intern abgespeicherte Parameter wird übergeben und wir speichern einen taskAwaiter. Der TaskAwaiter wird vom .Net Framework verwendet um auf die Fertigstellung eines Tasks zu warten.
Die nächste if Abfrage ist wieder besonders interessant.
if (!taskAwaiter.IsCompleted)
{
.
.
.
}
Hier wird überprüft ob der Task bereits in seinem completed Status ist oder nicht. Wenn er bereits Completed, also abgeschlossen ist, betreten wir den Block innerhalb der Abfrage nicht sondern laufen direkt weiter aus dem ersten Block heraus und landen in den Zeilen unter der ersten if else Verzweigung.
int result = taskAwaiter.GetResult();
taskAwaiter = default(TaskAwaiter<int>);
this.__result = result;
result2 = this.__result;
this.__state = -2;
this.__builder.SetResult(result2);
Hier wird das Result aus unserem Task geholt und abgespeichert. Am Ende wird der Status noch auf -2 und damit auf Completed gesetzt.
In diesem Fall, laufen wir also direkt durch die Methode durch der Thread muss nie gewechselt werden und es muss auch nirgendwo gewartet werden. Der ganze Bereich wird synchron abgearbeitet und die Ausführung unseres eigentlichen Programms geht sofort weiter. Das bedeutet für uns, dass wir nie ganz sicher sein können, dass unsere mit async Markierte Methode wirklich asynchron läuft. In diesem Fall wird sie einfach synchron abgearbeitet.
Schauen wir im nächsten Schritt an, was passiert wenn der Task beim ersten Aufruf von MoveNext() noch nicht abgeschlossen ist. Wir betreten wieder die erste if Abfrage und rufen wieder MyMethodAsync auf. Danach wird auch hier überprüft, ob der Task bereits abgeschlossen ist. Da dieser seine Aufgabe noch nicht beendet hat, betreten wir den Block unter dem if.
if (!taskAwaiter.IsCompleted)
{
this.__state = 0;
this.__taskAwaiter = taskAwaiter;
MethodAsyncStateMachine stateMachine = this;
this.__builder.AwaitUnsafeOnCompleted<TaskAwaiter<int>,MethodAsyncStateMachine>(
ref taskAwaiter, ref stateMachine);
return;
}
Innerhalb dieses Blocks ändern wir den Status auf 0 und sind damit im Wartemodus. Danach speichern wir uns unsere gerade verwendete State Machine und den taskAwaiter zwischen. Diese beiden Werte geben wir an den AsyncMethodBuilder weiter und verlassen die Methode. Durch das Verlassen geben wir den Thread wieder frei dadurch kann unsere Anwendung weiterarbeiten und Blockiert nicht.
Der AsyncMethodBuilder wartet so lange bis unser Task fertig ist und ruft unsere StateMachine, die wir ihm vorher übergeben haben wieder auf.
Da wir bei diesem Aufruf auf Status 0 sind, betreten wir den else Block. Hier wird der Status auf -1 gesetzt und der TaskAwaiter wird wieder zurückgesetzt. Wir verlassen den else Block also wieder und die State Machine beendet sich wie oben bei dem bereits abgeschlossenen Task beschrieben.
Einen Blick sollten wir noch auf den catch Block der try catch Anweisung werfen.
this.__state = -2;
this.__builder.SetException(exception);
Wir sehen hier, dass jede Exception abgefangen wird und unseren Maschine auch in diesem Fall auf den Status -2, also abgeschlossen, gesetzt wird. Zusätzlich wird die Methode SetException des AsyncMethodBuilders aufgerufen. Dies ermöglicht uns, Exceptions die innerhalb eines Tasks auftreten, auch in unserem eigentlichen Programmcode abzufangen und zu behandeln.
Jetzt haben wir einen kompletten Ablauf einer async/await Methode einmal durchgespielt. Wir kennen den Unterschied zwischen asynchron und multithreaded und wissen, dass der Ausführungskontext und damit auch der Thread, beim Betreten einer asynchronen Methode, gespeichert und wiederhergestellt wird. Wir haben gesehen, dass aus dem Aufruf einer asynchronen Methode eine StateMachine erzeugt wird. Wir konnten sehen, dass, wenn der Task schon Completed ist, bevor wir ihn aufrufen, dieser gar nicht weiter bearbeitet wird und wir sofort wieder mit dem eigentlichen Programm ablauf weitermachen können.
Best Practices
Zum Abschluss sollten wir noch ein paar best practices anschauen, um Fehler, die bei gemacht werden könnten zu erkennen und zu vermeiden.
Kommen wir als erstes zurück zum Wiederherstellen des Ausführungskontexts. Dies kann z.B. dazu führen, dass die Anwendung blockiert und nicht mehr weiter ausgeführt werden kann. Nehmen wir zum Beispiel an, dass wir eine Bibliothek erstellt haben, die von anderen Entwicklern genutzt werden soll und in der wir asynchrone Methoden anbieten. Die Entwickler, kennen sich mit async/await nicht aus, und benutzen Wait() Methode um in ihrem synchronen Aufruf auf den Task zu warten. Schauen wir uns das folgende Beispiel an:
Beispiel
private async void ButtonBase_OnClick(object sender, RoutedEventArgs e)
{
doWorkAsync().Wait();
}
private async Task doWorkAsync()
{
await Task.Delay(1000);
}
Wir behandeln hier ein ButtonClick Event und rufen unsere asynchrone Methode auf. In diesem Beispiel wartet die Methode einfach eine Sekunde. Wir Erinnern uns aber, dass wir die asynchrone Methode auf dem gleichen Thread betreten und das warten vom AsyncMethodBuilder gesteuert wird. Das Wait führt als dazu, dass der aktuelle Thread blockiert und auf das Ergebnis des Tasks wartet. So haben wir einen Deadlock erzeugt und nichts geht mehr.
Man kann allerdings steuern ob der Context wiederhergestellt wird oder ob einfach auf einem anderen Context weiter gearbeitet wird. Die Methode ConfigureAwait(), welche an einen await-Aufruf angehangen werden kann, steuert dieses Verhalten. ConfigureAwait hat einen Parameter continueOnCapturedContext. Der Standardwert für diese Methode ist true, dies bedeutet, dass der Context wiederhergestellt wird. Wenn dieser Methode allerdings mit false aufgerufen wird, wird der Context nicht wieder hergestellt und wir arbeiten unter Umständen nach dem await Aufruf auf einem anderen Thread weiter.
Den Rückgabewert void vermeiden
Als Rückgabetyp einer asynchronen Methode sollte man void vermeiden. Das liegt zum einen daran, dass auf eine void Methode nicht gewartet werden kann, da ja kein Task da ist, sondern auch daran, dass bei einer async void Methode keine Exceptions abgefangen werden können.
Async und await erleichtern uns auch das Arbeiten mit Exceptions. Eigentlich müssten wir beim Arbeiten mit Tasks, nachdem sie abgeschlossen sind, selber in den Task schauen, ob dieser faulted ist. Falls dies der Fall ist, müssen wir die Exception selber aus dem Task abrufenund verarbeiten. Dies wird uns bei async and await abgenommen, jedoch nur wenn auch ein Task zurückgegeben wird.
Beispiel
class Program
{
static void Main(string[] args)
{
DoSomething();
}
static async void DoSomething()
{
try
{
ThrowException();
}
catch (Exception exception)
{
// The exception is never caught here!
Console.WriteLine(exception);
}
}
static async void ThrowException()
{
throw new Exception("Oh nooooo!!!");
}
}
Das obige Beispiel zeigt den Fall wo die Exception nicht abgefangen wird. Asynchrone void Methoden sollten nur innerhalb eines Event Handlers, also am Start der asynchronen Kette verwendet werden. Der try catch Block sollte dann innerhalb der void Methode stehen.
Einmal async, immer async
Wenn wir mit async und await arbeiten sollten, wir überall async arbeiten. Also ab dem ersten Aufruf einer asynchronen Methode sollten wir durch alle Schichten hindurch asynchron bleiben.
Es kann aber durchaus passieren, dass wir mit alten Code arbeiten müssen, der noch kein async und await verwendet. Wenn wir dann eine asynchrone 3rd Party Bibliothek nutzen möchten, müssen wir den asynchronen Aufruf irgendwie wieder synchron bekommen. Oben haben wir gesehen, dass die Wait() Methode hier nicht hilfreich ist. Doch auch dafür bringt das Framework eine Lösung mit.
Der Task bringt eine Methode GetAwaiter() mit, diese sorgt dafür, dass auf unseren Aufruf gewartet wird, ohne dass der aktuelle Thread blockiert. Wenn wir das Ergebnis abrufen, können wir am Awaiter noch die GetResult() Methode aufrufen. Diese holt uns das Ergebnis aus dem abgeschlossenen Task.
Beispiel
private void MyMethod()
{
var myResult = SomeLibrary.DoSomethingAsync().GetAwaiter().GetResult();
}
Mit diesem Wissen ausgestattet sollte das arbeiten mit async und await kein Problem mehr sein und wir sollten in der Lage sein Fehler bei der asynchronen Programmierung zu erkennen und zu vermeiden.