Async/Await

Bewährte Verfahren bei der asynchronen Programmierung

Stephen Cleary

 

In diesen Tagen ist die neue async- und await-Unterstützung in Microsoft .NET Framework 4.5 in aller Munde. Dieser Artikel bietet weiterführende Informationen und richtet sich an Personen, die mindestens einen einführenden Artikel zu diesem Thema gelesen haben. Er enthält nichts völlig Neues, denn die gleichen Tipps finden sich z. B. schon bei Stack Overflow, in den MSDN-Foren, in den häufig gestellten Fragen zu async/await usw. Ich möchte in diesem Artikel aber einige bewährte Verfahren hervorheben, die in der Menge der verfügbaren Informationen und Quellen schnell untergehen können.

Die in diesem Artikel vorgestellten bewährten Verfahren dienen als Richtlinien und nicht als starre Regeln. Für jede dieser Richtlinien gibt es auch Ausnahmen. Ich werde jede Richtlinie ausführlich erläutern, um deutlich zu machen, wann sie zutrifft und wann nicht. Die Richtlinien sind in Abbildung 1 zusammengefasst dargestellt. In den folgenden Abschnitten gehe ich auf jede einzeln ein.

Abbildung 1: Übersicht über die Richtlinien für die asynchrone Programmierung

Name Beschreibung Ausnahmen
Vermeiden von async-Methoden mit „void“-Rückgabe Bevorzugen Sie async-Task-Methoden vor async-void-Methoden. Ereignishandler
Durchgehend asynchron Mischen Sie blockierenden und asynchronen Code nicht miteinander. Main-Methode für Konsolenanwendungen
Konfigurieren des Kontexts Verwenden Sie „ConfigureAwait(false)“, wenn möglich. Methoden, die Kontext erfordern

Vermeiden von async-Methoden mit „void“-Rückgabe

Für async-Methoden gibt es drei mögliche Rückgabetypen: „Task“, „Task<T>“ und „void“, aber die standardmäßigen Rückgabetypen für async-Methoden sind nur „Task“ und „Task<T>“. Beim Konvertieren von synchronem Code in asynchronen Code wird jede Methode, die einen Typ „T“ zurückgibt, zu einer async-Methode, die „Task<T>“ zurückgibt. Und jede Methode, die „void“ zurückgibt, wird zu einer async-Methode, die „Task“ zurückgibt. In folgendem Codeausschnitt werden eine synchrone, „void“ zurückgebende Methode und ihr asynchrones Äquivalent gezeigt:

void MyMethod()
{
  // Do synchronous work.
  Thread.Sleep(1000);
}
async Task MyMethodAsync()
{
  // Do asynchronous work.
  await Task.Delay(1000);
}

Async-Methoden, die „void“ zurückgeben, erfüllen einen bestimmten Zweck: Sie machen asynchrone Ereignishandler möglich. Sie können einen Ereignishandler verwenden, der einen tatsächlichen Typ zurückgibt. Das funktioniert in der Sprache aber nicht gut. Das Aufrufen eines Ereignishandlers, der einen Typ zurückgibt, ist sehr mühsam, und die Vorstellung von einem Ereignishandler, der etwas zurückgibt, erscheint nicht sinnvoll. Ereignishandler geben „void“ zurück, sodass die async-Methoden „void“ zurückgeben und Sie einen asynchronen Ereignishandler verwenden können. Einige Semantiken der async-void-Methoden unterscheiden sich jedoch leicht von den Semantiken von async-Task- oder async-Task<T>-Methoden.

Async-void-Methoden haben andere Semantiken für die Fehlerbehandlung. Wenn bei einer async-Task- oder async-Task<T>-Methode eine Ausnahme ausgegeben wird, wird sie im Task-Objekt erfasst und platziert. Bei den async-void-Methoden gibt es kein Task-Objekt, sodass alle Ausnahmen einer async-void-Methode direkt im „SynchronizationContext“ ausgelöst werden, der beim Starten der async-void-Methode aktiv war. In Abbildung 2 wird gezeigt, dass die von async-void-Methoden ausgegebenen Ausnahmen nicht auf normale Weise abgefangen werden können.

Abbildung 2: Ausnahmen einer async-void-Methode können nicht mit „catch“ abgefangen werden

private async void ThrowExceptionAsync()
{
  throw new InvalidOperationException();
}
public void AsyncVoidExceptions_CannotBeCaughtByCatch()
{
  try
  {
    ThrowExceptionAsync();
  }
  catch (Exception)
  {
    // The exception is never caught here!
    throw;
  }
}

Diese Ausnahmen können zwar mit „AppDomain.UnhandledException“ oder einem ähnlichen Catch-All-Ereignis für GUI/ASP.NET-Anwendungen verarbeitet werden, die Verwendung dieser Ereignisse für die allgemeine Ausnahmebehandlung erfordert jedoch einen sehr hohen Wartungsaufwand und ist daher nicht praktikabel.

Async-void-Methoden weisen auch andere Erstellungssemantiken auf. Async-Methoden, die „Task“ oder „Task<T>“ zurückgeben, können ganz einfach mit „await“, „Task.WhenAny“, „Task.WhenAll“ usw. erstellt werden. Async-Methoden, die „void“ zurückgeben, bieten keine einfache Möglichkeit, dem aufrufenden Code mitzuteilen, dass sie abgeschlossen sind. Es ist leicht, mehrere async-void-Methoden zu starten, es ist aber nicht leicht festzustellen, wann sie beendet wurden. Die async-void-Methoden teilen zwar dem SynchronizationContext ihren Start- und Endzeitpunkt mit, aber ein benutzerdefinierter SynchronizationContext stellt eine komplexe Lösung für einen regulären Anwendungscode dar.

Async-void-Methoden sind schwierig zu testen. Aufgrund der Unterschiede in der Fehlerbehandlung und in der Erstellung ist es schwierig, Komponententests zu schreiben, die async-void-Methoden aufrufen. Der MSTest für asynchrone Tests funktioniert nur für async-Methoden, die „Task“ oder „Task<T>“ zurückgeben. Es besteht die zwar Möglichkeit, einen SynchronizationContext zu installieren, der erkennt, wann alle void-Methoden abgeschlossen wurden, und der alle Ausnahmen erfasst, es ist jedoch wesentlich einfacher, die async-void-Methoden dazu zu bringen, „Task“ zurückzugeben.

Es ist klar, dass async-void-Methoden im Vergleich zu async-Task-Methoden verschiedene Nachteile aufweisen, in einem Fall sind sie jedoch sehr hilfreich: für asynchrone Ereignishandler. Die Unterschiede in den Semantiken ergeben bei den asynchronen Ereignishandlern Sinn. Sie lösen die Ausnahmen ähnlich wie synchrone Ereignishandler direkt im SynchronizationContext aus. Synchrone Ereignishandler sind in der Regel privat und können daher nicht erstellt oder direkt getestet werden. Ich minimiere gerne den Code in meinem asynchronen Ereignishandler und lasse ihn z. B. auf eine async-Task-Methode warten, die die eigentliche Logik enthält. Der folgende Code entspricht diesem Ansatz und verwendet async-void-Methoden für Ereignishandler, ohne die Testbarkeit zu gefährden:

private async void button1_Click(object sender, EventArgs e)
{
  await Button1ClickAsync();
}
public async Task Button1ClickAsync()
{
  // Do asynchronous work.
  await Task.Delay(1000);
}

Async-void-Methoden können Chaos verursachen, wenn der Aufrufende nicht erwartet, dass sie asynchron sind. Lautet der Rückgabetyp „Task“ weiß der Aufrufer, dass es um eine zukünftige Operation geht; lautet der Rückgabetyp „void“, geht der Aufrufer möglicherweise davon aus, dass die Methode zum Zeitpunkt der Rückgabe bereits abgeschlossen ist. Dieses Problem kann auf vielerlei und unerwartete Art und Weise auftreten. Es ist in der Regel falsch, eine async-Implementierung (oder Außerkraftsetzung) einer Methode, die „void“ zurückgibt, für eine Schnittstelle (oder Basisklasse) bereitzustellen. Einige Ereignisse gehen außerdem davon aus, dass ihre Handler bei Rückgabe abgeschlossen sind. Eine gefährliche Falle ist die Übergabe eines async-Lambdas an eine Methode, die einen Aktionsparameter verwendet: In diesem Fall gibt der async-Lambda „void“ zurück und erbt alle Probleme der async-void-Methoden. Als Faustregel gilt, dass async-Lambdas nur verwendet werden sollten, wenn sie in einen Delegattyp konvertiert wurden, der „Task“ zurückgibt (z. B. „Func<Task>“).

Zusammenfassung dieser ersten Richtlinie: Sie sollten lieber async-Task-Methoden als async-void-Methoden verwenden. Async-Task-Methoden erleichtern die Fehlerbehandlung, Erstellbarkeit und Testbarkeit. Eine Ausnahme von dieser Richtlinie bilden asynchrone Ereignishandler, die „void“ zurückgeben müssen. Zu dieser Ausnahme zählen auch Methoden, die logische Ereignishandler sind, auch wenn sie keine wirklichen Ereignishandler sind (z. B. ICommand.Execute-Implementierungen).

Durchgehend asynchron

Bei asynchronem Code muss ich an die Geschichte eines Mannes denken, der äußerte, dass die Welt im Weltraum schwebt, jedoch von einer älteren Dame belehrt wurde, dass sich die Welt auf dem Rücken einer gigantischen Schildkröte befände. Als der Mann fragte, auf was die Schildkröte denn stehe, antwortete die Dame „Sie sind sehr schlau, junger Mann, aber die Schildkröten stehen wieder auf anderen Schildkröten und so weiter – bis ganz nach unten“. Wenn Sie synchronen Code in asynchronen Code konvertieren, werden Sie feststellen, dass der asynchrone Code am besten funktioniert, wenn er anderen asynchronen Code aufruft und von diesem aufgerufen wird und so weiter – bis ganz nach unten (oder oben, wenn Sie so wollen). Auch andere haben dieses Ausbreitungsverhalten beim asynchronen Programmieren bemerkt und haben es als „ansteckend“ bezeichnet oder mit einem Zombie-Virus verglichen. Ob nun Schildkröten oder Zombies – es stimmt auf jeden Fall, dass asynchroner Code dazu tendiert, aus dem umgebenden Code ebenfalls asynchronen Code zu machen. Dieses Verhalten tritt bei allen Typen des asynchronen Programmierens auf, nicht nur bei den neuen async/await-Schlüsselwörtern.

„Durchgehend asynchron“ bedeutet, dass Sie synchronen und asynchronen Code nicht mischen sollten, ohne gründlich über die Folgen nachgedacht zu haben. Es ist vor allem eine schlechte Idee, asynchronen Code durch Aufrufen von „Task.Wait“ oder „Task.Result“ zu blockieren. Das ist ein häufiges Problem bei Entwicklern, die sich „nur mal eben“ mit asynchroner Programmierung beschäftigen, daher nur einen kleinen Teil ihrer Anwendung konvertieren und diesen in eine synchrone API einschließen, sodass der Rest der Anwendungen von den Änderungen isoliert bleibt. Leider bekommen sie dabei Probleme mit Deadlocks. Nachdem ich viele Fragen zu „async“ in den MSDN-Foren, bei Stack Overflow und per E-Mail beantwortet habe, kann ich sagen, dass die am häufigsten gestellte Frage bei async-Neulingen, die gerade die Grundlagen erlernt haben, wie folgt lautet: „Warum treten bei meinem teilweise asynchronen Code Deadlocks auf?”

In Abbildung 3 ist ein einfaches Beispiel zu sehen, bei dem eine Methode das Ergebnis einer async-Methode blockiert. Dieser Code funktioniert gut in einer Konsolenanwendung, erzeugt aber einen Deadlock, wenn er über einen GUI- oder ASP.NET-Kontext aufgerufen wird. Dieses Verhalten kann verwirrend sein, vor allem, weil beim Durchlaufen des Debuggers der Eindruck entsteht, dass das await-Element nicht abgeschlossen wird. Die tatsächliche Ursache des Deadlocks liegt aber weiter oben in der Aufrufliste, nämlich beim Aufruf von „Task.Wait“.

Abbildung 3: Ein häufiges Deadlockproblem durch Blockieren von asynchronem Code

public static class DeadlockDemo
{
  private static async Task DelayAsync()
  {
    await Task.Delay(1000);
  }
  // This method causes a deadlock when called in a GUI or ASP.NET context.
  public static void Test()
  {
    // Start the delay.
    var delayTask = DelayAsync();
    // Wait for the delay to complete.
    delayTask.Wait();
  }
}

Die Ursache dieses Deadlocks besteht in der Art und Weise, wie das „await“-Element Kontexte verarbeitet. Beim Warten auf eine unvollständige Aufgabe wird standardmäßig der aktuelle „Kontext“ erfasst und zum Fortsetzen der Methode verwendet, wenn die Aufgabe abgeschlossen ist. Dieser „Kontext“ ist der aktuelle SynchronizationContext, sofern er nicht NULL ist; ansonsten ist es der aktuelle TaskScheduler. GUI- und ASP.NET-Anwendungen weisen einen SynchronizationContext auf, der die Ausführung immer nur eines Codeteils zulässt. Wenn das await-Element abgeschlossen wurde, versucht es, den Rest der async-Methode im erfassten Kontext auszuführen. Dieser Kontext enthält aber bereits einen Thread, der (synchron) darauf wartet, dass die async-Methode abgeschlossen wird. Sie warten aufeinander und verursachen so einen Deadlock.

Bei Konsolenanwendungen tritt dieser Deadlock nicht auf. Diese verfügen über einen Threadpool-SynchronizationContext anstatt des SynchronizationContext, der immer nur einen Codeteil zulässt, sodass das await-Element bei Beendigung den Rest der async-Methode in einem Threadpoolthread planen kann. Die Methode kann abgeschlossen werden, wodurch auch die zurückgegebene Aufgabe abgeschlossen werden kann und kein Deadlock entsteht. Dieses unterschiedliche Verhalten kann für Entwickler verwirrend sein, wenn sie ein Testkonsolenprogramm schreiben, dabei feststellen, dass der teilweise asynchrone Code wie erwartet funktioniert, und dann den gleichen Code in einer GUI- oder ASP.NET-Anwendung einsetzen, in der er dann einen Deadlock erzeugt.

Die beste Lösung für dieses Problem ist, das natürliche Ausbreiten des asynchronen Codes in der Codebasis nicht zu unterbinden. Befolgen Sie diese Lösung, breitet sich der asynchrone Code bis zu seinem Einstiegspunkt aus, in der Regel ein Ereignishandler oder eine Controlleraktion. Bei Konsolenanwendungen kann diese Lösung nicht vollständig eingehalten werden, da die Main-Methode nicht asynchron sein kann. Wäre die Main-Methode asynchron, könnte sie vor ihrer Beendigung eine Rückgabe vornehmen und das Programm dadurch beenden. In Abbildung 4 wird die Ausnahme von dieser Richtlinie gezeigt: Die Main-Methode für eine Konsolenanwendung ist eine der wenigen Situationen, in denen Code eine asynchrone Methode blockieren kann.

Abbildung 4: Die Main-Methode kann „Task.Wait“ oder „Task.Result“ aufrufen

class Program
{
  static void Main()
  {
    MainAsync().Wait();
  }
  static async Task MainAsync()
  {
    try
    {
      // Asynchronous implementation.
      await Task.Delay(1000);
    }
    catch (Exception ex)
    {
      // Handle exceptions.
    }
  }
}

Die Ausbreitung des asynchronen Codes über die Codebasis zuzulassen, ist die beste Lösung, bedeutet aber auch, dass anfänglich viel Arbeit in die Anwendung investiert werden muss, damit sie vom asynchronen Code profitiert. Es gibt einige Techniken, mit denen eine große Codebasis in asynchronen Code konvertiert werden kann, dieses Thema würde jedoch den Rahmen des Artikels sprengen. In einigen Fällen kann „Task.Wait“ oder „Task.Result“ mit einer teilweisen Konvertierung hilfreich sein, Sie müssen dabei jedoch das Deadlock-Problem und das Fehlerbehandlungsproblem beachten. Auf das Fehlerbehandlungsproblem werde ich im Folgenden eingehen und später im Artikel zeigen, wie das Deadlock-Problem verhindert werden kann.

In jeder Aufgabe wird eine Liste von Ausnahmen gespeichert. Wenn Sie auf eine Aufgabe warten, wird die erste Ausnahme erneut ausgegeben, sodass Sie den spezifischen Ausnahmetyp (z. B. „InvalidOperationException“) erfassen können. Wenn Sie jedoch eine Aufgabe mit „Task.Wait“ oder „Task.Result“ synchron blockieren, werden alle Ausnahmen in einer „AggregateException“ eingeschlossen und ausgegeben. Schauen Sie sich noch einmal die Abbildung 4 an. Das try/catch-Element in „MainAsync“ fängt einen spezifischen Ausnahmetyp ab, wenn Sie das try/catch-Element jedoch in „Main“ einfügen, fängt es immer eine „AggregateException“ ab. Die Fehlerbehandlung ist ohne „AggregateException“ wesentlich einfacher, sodass ich das „globale“ try/catch-Element in „MainAsync“ einfüge.

Bisher bin ich auf zwei Probleme beim Blockieren von asynchronem Code eingegangen: mögliche Deadlocks und komplizierte Fehlerbehandlung. Es gibt noch ein weiteres Problem, das auftritt, wenn blockierender Code innerhalb einer async-Methode verwendet wird. Betrachten Sie folgendes einfaches Beispiel:

public static class NotFullyAsynchronousDemo
{
  // This method synchronously blocks a thread.
  public static async Task TestNotFullyAsync()
  {
    await Task.Yield();
    Thread.Sleep(5000);
  }
}

Diese Methode ist nicht vollständig asynchron. Sie gibt sofort eine unvollständige Aufgabe zurück, blockiert aber im weiteren Verlauf jedweden laufenden Thread synchron. Wird diese Methode von einem GUI-Kontext aufgerufen, blockiert sie den GUI-Thread; wird sie von einem ASP.NET-Anforderungskontext aufgerufen, blockiert sie den aktuellen ASP.NET-Anforderungsthread. Asynchroner Code funktioniert am besten, wenn er nicht synchron blockiert. Abbildung 5 dient als Leitfaden für asynchrone Ersetzungen in synchronen Operationen.

Abbildung 5: Der „asynchrone Ansatz“

Zweck Nicht verwenden Stattdessen verwenden
Abrufen des Ergebnisses einer Hintergrundaufgabe „Task.Wait“ oder „Task.Result“ await
Warten auf das Abschließen einer Aufgabe Task.WaitAny await Task.WhenAny
Abrufen der Ergebnisse mehrerer Aufgaben Task.WaitAll await Task.WhenAll
Warten über einen bestimmten Zeitraum Thread.Sleep await Task.Delay

Zusammenfassung dieser zweiten Richtlinie: Sie sollten asynchronen und blockierenden Code nicht miteinander mischen. Gemischter asynchroner und blockierender Code kann zu Deadlocks, komplexer Fehlerbehandlung und unerwartetem Blockieren von Kontextthreads führen. Eine Ausnahme von dieser Richtlinie bildet die Main-Methode für Konsolenanwendungen oder – wenn Sie ein fortgeschrittener Benutzer sind – das Verwalten einer teilweise asynchronen Codebasis.

Konfigurieren des Kontexts

Weiter oben in diesem Artikel habe ich kurz erklärt, wie der „Kontext“ standardmäßig erfasst wird, wenn auf eine unvollständige Aufgabe gewartet wird, und dass dieser erfasste Kontext zum Fortsetzen der async-Methode eingesetzt wird. In Abbildung 3 wird gezeigt, wie das Fortsetzen mit diesem Kontext einen Konflikt mit dem synchronen Blockieren und dadurch einen Deadlock verursacht. Dieses Kontextverhalten kann auch noch zu einem anderen Problem führen: die Leistung wird beeinträchtigt. Wenn asynchrone GUI-Anwendungen größer werden, gibt es möglicherweise viele kleine Teile von async-Methoden, die den GUI-Thread als Kontext verwenden. Dies kann in Summe zu einer Verlangsamung des Prozesses führen.

Um dies zu verhindern, sollten Sie am besten immer auf das Ergebnis von „ConfigureAwait“ warten. Im folgenden Codeausschnitt werden das Standardkontextverhalten und die Verwendung von „ConfigureAwait“ veranschaulicht:

async Task MyMethodAsync()
{
  // Code here runs in the original context.
  await Task.Delay(1000);
  // Code here runs in the original context.
  await Task.Delay(1000).ConfigureAwait(
    continueOnCapturedContext: false);
  // Code here runs without the original
  // context (in this case, on the thread pool).
}

Durch die Verwendung von „ConfigureAwait“ wird ein gewisser Grad an Parallelismus ermöglicht: Asynchroner Code kann parallel zum GUI-Thread ausgeführt werden, sodass er nicht ständig mit Aufgaben „belästigt“ werden muss.

Neben der Leistung weist „ConfigureAwait“ noch einen anderen wichtigen Aspekt auf: Damit lassen sich Deadlocks vermeiden. Schauen Sie sich noch einmal Abbildung 3 an; wenn Sie in „DelayAsync“ der Codezeile „ConfigureAwait(false)“ hinzufügen, wird der Deadlock vermieden. Wenn das await-Element abgeschlossen wurde, versucht es diesmal, den Rest der async-Methode im Threadpoolkontext auszuführen. Die Methode kann abgeschlossen werden, wodurch auch die zurückgegebene Aufgabe abgeschlossen werden kann und kein Deadlock entsteht. Dieser Ansatz ist vor allem hilfreich, wenn Sie eine Anwendung nach und nach von synchron in asynchron konvertieren müssen.

Wenn Sie „ConfigureAwait“ an einem bestimmten Punkt in einer Methode verwenden können, sollten Sie es für jedes await-Element nach diesem Punkt verwenden. Rufen Sie sich in Erinnerung, dass der Kontext nur erfasst wird, wenn auf eine unvollständige Aufgabe gewartet wird; ist die Aufgabe bereits abgeschlossen, wird der Kontext nicht erfasst. Einige Aufgaben werden je nach Hardware- und Netzwerkbedingungen möglicherweise schneller als erwartet abgeschlossen, und Sie müssen dann die zurückgegebene Aufgabe verarbeiten. In Abbildung 6 wird ein angepasstes Beispiel gezeigt.

Abbildung 6: Verarbeiten einer zurückgegebenen Aufgabe, die früher als erwartet abgeschlossen wurde

async Task MyMethodAsync()
{
  // Code here runs in the original context.
  await Task.FromResult(1);
  // Code here runs in the original context.
  await Task.FromResult(1).ConfigureAwait(continueOnCapturedContext: false);
  // Code here runs in the original context.
  var random = new Random();
  int delay = random.Next(2); // Delay is either 0 or 1
  await Task.Delay(delay).ConfigureAwait(continueOnCapturedContext: false);
  // Code here might or might not run in the original context.
  // The same is true when you await any Task
  // that might complete very quickly.
}

Sie sollten „ConfigureAwait“ nicht verwenden, wenn nach dem await-Element in der Methode Code folgt, der den Kontext benötigt. Bei GUI-Anwendungen trifft dies auf jeglichen Code zu, der GUI-Elemente bearbeitet, datengebundene Eigenschaften schreibt oder von einem GUI-spezifischen Typ wie „Dispatcher/CoreDispatcher“ abhängt. Bei ASP.NET-Anwendungen trifft dies auf jeglichen Code zu, der „HttpContext.Current“ verwendet oder eine ASP.NET-Antwort erstellt, einschließlich return-Anweisungen in Controlleraktionen. In Abbildung 7 wird ein häufiges Muster in GUI-Anwendungen gezeigt: Ein asynchroner Ereignishandler deaktiviert seine Steuerung am Anfang der Methode, führt einige await-Elemente aus und aktiviert seine Steuerung am Ende Handlers dann wieder; der Ereignishandler kann nicht auf seinen Kontext verzichten, da er die Steuerung wieder aktivieren muss.

Abbildung 7: Ein asynchroner Ereignishandler deaktiviert und aktiviert seine Steuerung

private async void button1_Click(object sender, EventArgs e)
{
  button1.Enabled = false;
  try
  {
    // Can't use ConfigureAwait here ...
    await Task.Delay(1000);
  }
  finally
  {
    // Because we need the context here.
    button1.Enabled = true;
  }
}

Jede async-Methode weist einen eigenen Kontext auf. Wenn eine async-Methode eine andere async-Methode aufruft, sind ihre Kontexte daher unabhängig. Abbildung 8 entspricht Abbildung 7 mit einer kleinen Änderung.

Abbildung 8: Jede async-Methode weist ihren eigenen Kontext auf

private async Task HandleClickAsync()
{
  // Can use ConfigureAwait here.
  await Task.Delay(1000).ConfigureAwait(continueOnCapturedContext: false);
}
private async void button1_Click(object sender, EventArgs e)
{
  button1.Enabled = false;
  try
  {
    // Can't use ConfigureAwait here.
    await HandleClickAsync();
  }
  finally
  {
    // We are back on the original context for this method.
    button1.Enabled = true;
  }
}

Kontextfreier Code lässt sich einfacher wiederverwenden. Versuchen Sie in Ihrem Code eine Barriere zwischen dem kontextabhängigen und dem kontextfreien Code zu schaffen und den kontextabhängigen Code zu minimieren. In Abbildung 8 empfehle ich, die gesamte zentrale Logik des Ereignishandlers in eine testfähige und kontextfreie asynchrone Task-Methode einzufügen, sodass nur der minimale Code im kontextabhängigen Ereignishandler zurückbleibt. Auch wenn Sie eine ASP.NET-Anwendung schreiben, sollten Sie die Verwendung von „ConfigureAwait“ im Bibliothekscode in Erwägung ziehen, sofern die Kernbibliothek potenziell gemeinsam mit Desktopanwendungen genutzt wird.

Zusammenfassung dieser dritten Richtlinie: Sie sollten wann immer möglich „Configure­Await“ verwenden. Kontextfreier Code führt zu einer besseren Leistung bei GUI-Anwendungen und ist eine hilfreiche Methode zum Vermeiden von Deadlocks beim Arbeiten mit einer teilweise asynchronen Codebasis. Ausnahmen von dieser Richtlinie bilden Methoden, die Kontext erfordern.

Kennen der Werkzeuge

Bei der Verwendung von „async“ und „await“ gibt es vieles zu bedenken, und es ist normal, dass man anfangs schnell die Orientierung verliert. In Abbildung 9 finden Sie Lösungen für häufige Probleme.

Abbildung 9: Lösungen für häufige async-Probleme

Problem Lösung
Erstellen einer Aufgabe zum Ausführen von Code Task.Run oder TaskFactory.StartNew (nicht der Task-Konstruktor oder Task.Start)
Erstellen eines Aufgabenwrappers für eine Operation oder ein Ereignis TaskFactory.FromAsync oder TaskCompletionSource<T>
Unterstützung für Abbrüche CancellationTokenSource und CancellationToken
Statusberichterstellung IProgress<T> und Progress<T>
Verarbeiten von Datenströmen TPL Dataflow oder Reactive Extensions
Synchronisieren des Zugriffs auf eine gemeinsam genutzte Ressource SemaphoreSlim
Asynchrone Initialisierung einer Ressource AsyncLazy<T>
Async-fähige Erzeuger-Verbraucher-Strukturen TPL Dataflow oder AsyncCollection<T>

Das erste Problem ist das der Aufgabenerstellung. Natürlich kann eine async-Methode eine Aufgabe erstellen, und das ist auch die einfachste Option. Wenn Sie Code im Threadpool ausführen müssen, verwenden Sie „Task.Run“. Wenn Sie einen Aufgabenwrapper für eine bestehende asynchrone Operation oder ein bestehendes Ereignis erstellen möchten, verwenden Sie „TaskCompletionSource<T>“. Das nächste häufige Problem ist das der Verarbeitung von Abbrüchen und von Statusberichten. Die Basisklassenbibliothek (Base Class Library, BCL) enthält Typen, die speziell zum Lösen dieser Probleme konzipiert wurden: CancellationTokenSource/CancellationToken und IProgress<T>/Progress<T>. Im asynchronen Code sollte das aufgabenbasierte asynchrone Muster (TAP, Task-based Asynchronous Pattern) verwendet werden (msdn.microsoft.com/library/hh873175), das die Taskerstellung, Abbrüche und Statusberichterstellung detailliert erläutert.

Ein anderes Problem ist die Verarbeitung von asynchronen Datenströmen. Aufgaben sind praktisch, können aber nur ein Objekt zurückgeben und auch nur einmal abgeschlossen werden. Bei asynchronen Strömen können Sie entweder TPL Dataflow oder Reactive Extensions (Rx) verwenden. TPL Dataflow erstellt ein akteurbasiertes „Netz“. Rx ist leistungsfähiger und effizienter, aber recht schwer zu erlernen. Sowohl TPL Dataflow als auch Rx weisen async-fähige Methoden auf und funktionieren gut mit asynchronem Code.

Nur weil Ihr Code asynchron ist, ist er noch nicht sicher. Gemeinsam genutzte Ressourcen müssen weiterhin geschützt werden, und dies ist kompliziert, da Sie das await-Element nicht innerhalb einer Sperre verwenden können. Im Folgenden sehen Sie ein Beispiel für asynchronen Code, der den gemeinsam genutzten Status beeinträchtigen kann, wenn er zwei Mal ausgeführt wird, selbst wenn er immer im gleichen Thread ausgeführt wird:

int value;

Task<int> GetNextValueAsync(int current);

async Task UpdateValueAsync()

{

  value = await GetNextValueAsync(value);

}

Das Problem ist hier, dass die Methode den Wert liest und sich selbst beim await-Element anhält. Wenn die Methode fortgesetzt wird, geht sie davon aus, dass sich der Wert nicht geändert hat. Um dieses Problem zu beheben, wurde die SemaphoreSlim-Klasse um async-fähige WaitAsync-Überladungen erweitert. In Abbildung 10 wird „SemaphoreSlim.WaitAsync“ veranschaulicht.

Abbildung 10: „SemaphoreSlim“ ermöglicht die asynchrone Synchronisierung

SemaphoreSlim mutex = new SemaphoreSlim(1);

int value;

Task<int> GetNextValueAsync(int current);

async Task UpdateValueAsync()

{

  await mutex.WaitAsync().ConfigureAwait(false);

  try

  {

    value = await GetNextValueAsync(value);

  }

  finally

  {

    mutex.Release();

  }

}

Asynchroner Code wird häufig zum Initialisieren einer Ressource eingesetzt, die dann zwischengespeichert und freigegeben wird. Es gibt dafür keinen integrierten Typ, aber Stephen Toub hat einen AsyncLazy<T>-Typ entwickelt, der wie eine Kombination von „Task<T>“ und „Lazy<T>“ funktioniert. Der ursprüngliche Typ wird in seinem Blog (bit.ly/dEN178) beschrieben, und eine aktualisierte Version ist in meiner AsyncEx-Bibliothek verfügbar (nitoasyncex.codeplex.com).

Schließlich sind manchmal auch async-fähige Datenstrukturen erforderlich. TPL Dataflow bietet ein BufferBlock<T>-Element, das wie eine async-fähige Erzeuger-Verbraucher-Warteschlange fungiert. Alternativ dazu bietet „AsyncEx“ das AsyncCollection<T>-Element, bei dem es sich um eine asynchrone Version von „BlockingCollection<T>“ handelt.

Ich hoffe, die Richtlinien und Verweise in diesem Artikel waren hilfreich für Sie. Async ist ein wirklich tolles Sprachfeature, und jetzt ist genau der richtige Moment, um es auszuprobieren!

Stephen Cleary lebt als Ehemann, Vater und Entwickler in den USA im Norden von Michigan. Seit 16 Jahren beschäftigt er sich mit Multithreading und asynchroner Programmierung und nutzt die async-Unterstützung in Microsoft .NET Framework seit der ersten CTP-Version. Seine Homepage und seinen Blog finden Sie unter stephencleary.com.

Unser Dank gilt dem folgenden technischen Experten für die Durchsicht dieses Artikels: Stephen Toub
Stephen Toub arbeitet im Visual Studio-Team von Microsoft. Er beschäftigt sich mit Themen wie Parallelismus und Asynchronität.