← Zur ├ťbersicht

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.

NoThread

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.