Asynchrone Programmierung

Wiedergabe und Pause mit „await“

Mads Torgersen

Beispielcode herunterladen

Die asynchronen Methoden in den bevorstehenden Versionen von Visual Basic und C# bieten eine hervorragende Möglichkeit, Rückrufe aus der asynchronen Programmierung zu entfernen. In diesem Artikel beschäftige ich mich ausführlich mit der tatsächlichen Funktionsweise des neuen Schlüsselworts „await“. Ich beginne auf der konzeptionellen Ebene und arbeite mich dann zum Kern der Sache vor.

Sequenzielle Komposition

Visual Basic und C# sind imperative Programmiersprachen – und stolz darauf! Sie zeichnen sich also dadurch aus, dass Programmierlogik als Sequenz diskreter Schritte ausgedrückt werden kann, die einer nach dem anderen ausgeführt werden. Die meisten Sprachkonstrukte auf Anweisungsebene sind Steuerungsstrukturen, die verschiedene Möglichkeiten zur Angabe der Reihenfolge bieten, in der die diskreten Schritte eines bestimmten Codetexts ausgeführt werden sollen:

  • Bedingungsanweisungen wie „if“ und „switch“ ermöglichen je nach aktueller Situation die Auswahl verschiedener nachfolgender Aktionen.
  • Unter Verwendung von Schleifenanweisungen wie „for“, „foreach“ und „while“ kann eine bestimmte Reihe von Schritten mehrmals wiederholt werden.
  • Durch Anweisungen wie „continue“, „throw“ und „goto“ kann die Steuerung auf nicht lokale Art und Weise an andere Programmteile übergeben werden.

Wenn Sie Logik unter Verwendung von Steuerungsstrukturen erstellen, entsteht eine sequenzielle Komposition. Und die ist der zentrale Bestandteil der imperativen Programmierung. Das ist der eigentliche Grund dafür, dass so viele Steuerungsstrukturen zur Auswahl stehen: Die sequenzielle Komposition soll unbedingt zweckmäßig und gut strukturiert sein.

Kontinuierliche Ausführung

In den meisten imperativen Sprachen, einschließlich der aktuellen Versionen von Visual Basic und C#, werden Methoden (oder Funktionen, Prozeduren, wie immer sie auch genannt werden) kontinuierlich ausgeführt. Damit meine ich Folgendes: Sobald ein Kontrollthread mit der Ausführung einer bestimmten Methode begonnen hat, ist er permanent damit beschäftigt, bis die Ausführung der Methode beendet ist. Mitunter führt der Thread auch Anweisungen in Methoden aus, die von Ihrem Codetext aufgerufen werden, aber das ist nur Teil der Ausführung der Methode. Der Thread wechselt niemals zu einer Aktion, wenn er von der Methode nicht dazu aufgefordert wurde.

Diese Kontinuität ist manchmal problematisch. Manchmal kann eine Methode nichts tun, um voranzukommen – sie kann nur warten, dass etwas passiert: ein Download, ein Dateizugriff, eine Berechnung in einem anderen Thread oder das Eintreten eines bestimmten Zeitpunkts. In solchen Situationen ist der Thread vollständig mit dem Nichtstun beschäftigt. In der Regel spricht man davon, dass der Thread blockiert ist. Die Methode, die dies verursacht, wird als die blockierende Methode bezeichnet.

Dies ist ein Beispiel für eine blockierende Methode:

static byte[] TryFetch(string url)
{
  var client = new WebClient();
  try
  {
    return client.DownloadData(url);
  }
  catch (WebException) { }
  return null;
}

Ein Thread, der diese Methode ausführt, steht während eines Großteils des Aufrufs von client.DownloadData still. Er arbeitet nicht wirklich, sondern wartet nur.

Das ist nicht gut, wenn Threads wichtig sind – und das sind sie häufig. In einer typischen mittleren Ebene ist die Kommunikation mit dem Back-End oder einem anderen Dienst erforderlich, um alle Anforderungen der Reihe nach zu verarbeiten. Wenn jede Anforderung von einem eigenen Thread verarbeitet wird und diese Threads die meiste Zeit durch das Warten auf Zwischenergebnisse blockiert sind, kann die reine Anzahl der Threads in der mittleren Ebene leicht zu einem Leistungsengpass führen.

Der wichtigste Threadtyp ist wahrscheinlich der UI-Thread: Es gibt nur einen davon. Praktisch alle Benutzeroberflächenframeworks sind Singlethreadframeworks. Alles, was mit der Benutzeroberfläche in Zusammenhang steht, z. B. Ereignisse, Updates, die UI-Bearbeitungslogik eines Benutzers, muss in demselben dedizierten Thread erfolgen. Wenn eine dieser Aktivitäten zu warten beginnt (beispielsweise ein Ereignishandler, der sich für den Download von einem URL entscheidet), kommt die gesamte Benutzeroberfläche nicht voran, da ihr Thread mit dem Nichtstun beschäftigt ist.

Abhilfe wird geschaffen, wenn mehrere sequenzielle Aktivitäten Threads gemeinsam nutzen können. Hierzu müssen sie mitunter eine Pause einlegen, d. h., sie müssen in ihrer Ausführung Lücken lassen, in denen andere Aktivitäten in demselben Thread ausgeführt werden können. Kurz gesagt, sie müssen manchmal diskontinuierlich sein. Es ist sehr praktisch, wenn diese sequenziellen Aktivitäten diese Pause machen, wenn sie sowieso nichts tun. Die Rettung ist die asynchrone Programmierung!

Asynchrone Programmierung

Da Methoden heutzutage immer kontinuierlich sind, müssen diskontinuierliche Aktivitäten (beispielsweise das Vorher und das Nachher eines Downloads) in mehrere Methoden aufgeteilt werden. Um mitten in der Ausführung einer Methode eine Lücke zu schaffen, müssen Sie sie in ihre kontinuierlichen Einzelteile aufteilen. APIs können dabei behilflich sein, da sie asynchrone (nicht blockierende) Versionen von Methoden mit langer Laufzeit anbieten, die den Vorgang initiieren (beispielsweise einen Download starten), einen übergebenen Rückruf für die Ausführung bei Beendigung speichern und dann sofort an den Aufrufer zurückgeben. Aber damit der Aufrufer den Rückruf bereitstellen kann, müssen die nachfolgenden Aktivitäten (das Nachher) in eine gesonderte Methode ausgelagert werden.

Für die oben aufgeführte TryFetch-Methode funktioniert das so:

static void TryFetchAsync(string url, Action<byte[], Exception> callback)
{
  var client = new WebClient();
  client.DownloadDataCompleted += (_, args) =>
  {
    if (args.Error == null) callback(args.Result, null);
    else if (args.Error is WebException) callback(null, null);
    else callback(null, args.Error);
  };
  client.DownloadDataAsync(new Uri(url));
}

Es gibt verschiedene Möglichkeiten, Rückrufe zu übergeben: Die DownloadDataAsync-Methode setzt voraus, dass ein Ereignishandler für das DownloadDataCompleted-Ereignis registriert wurde. Auf diese Weise übergeben Sie den hinteren Teil der Methode (das Nachher). TryFetchAsync selbst muss auch die Rückrufe der eigenen Aufrufer verarbeiten. Statt das gesamte Ereignis selbst einzurichten, verwenden Sie einfach einen Rückruf als Parameter. Gut, dass wir einen Lambda-Ausdruck für den Ereignishandler verwenden können, sodass der Rückrufparameter direkt erfasst und verwendet werden kann. Wenn Sie eine benannte Methode verwenden, müssen Sie sich eine Möglichkeit einfallen lassen, wie der Rückruf an den Ereignishandler delegieren kann. Überlegen Sie einen Moment, wie Sie diesen Code ohne Lambdas schreiben würden.

Das Wichtigste aber ist, sich darüber im Klaren zu sein, wie die Ablaufsteuerung sich geändert hat. Statt den Ablauf mit den Steuerungsstrukturen einer Sprache auszudrücken, emulieren Sie sie:

  • Die return-Anweisung wird durch den Aufruf des Rückrufs emuliert.
  • Die implizite Propagierung von Ausnahmen wird durch den Aufruf des Rückrufs emuliert.
  • Die Ausnahmebehandlung wird durch eine Typprüfung emuliert.

Das ist natürlich ein sehr einfaches Beispiel. Mit zunehmender Komplexität der gewünschten Steuerungsstruktur wird auch die Emulation komplexer.

Fassen wir zusammen: Wir haben Diskontinuität erzielt und somit die Möglichkeit, den Ausführungsthread anzuweisen, eine andere Aktivität auszuführen, während er auf den Download „wartet“. Aber wir können den Ablauf nicht mehr einfach mit Steuerungsstrukturen ausdrücken. Wir haben die strukturierte imperative Sprache aufgegeben.

Asynchrone Methoden

Unter diesem Gesichtspunkt wird der Vorteil der asynchronen Methoden in den nächsten Versionen von Visual Basic und C# deutlich: Mit diesen Methoden können Sie diskontinuierlichen sequenziellen Code ausdrücken.

 Nachfolgend finden Sie die asynchrone Version von TryFetch mit dieser neuen Syntax:

static async Task<byte[]> TryFetchAsync(string url)
{
  var client = new WebClient();
  try
  {
    return await client.DownloadDataTaskAsync(url);
  }
  catch (WebException) { }
  return null;
}

Asynchrone Methoden ermöglichen die Pause in der Zeile, mitten im Code: Sie können nicht nur sequenzielle Komposition mit Ihren bevorzugten Steuerungsstrukturen ausdrücken, sondern auch mit await-Ausdrücken Lücken in der Ausführung schaffen – Lücken, in denen der Ausführungsthread andere Aktivitäten ausführen kann.

Man kann sich das in etwa so vorstellen, als ob die asynchronen Methoden Pause- und Wiedergabetasten hätten. Erreicht der Ausführungsthread einen await-Ausdruck, drückt er die Pausetaste, und die Methodenausführung wird ausgesetzt. Wenn der erwartete Task abgeschlossen ist, drückt er die Wiedergabetaste, und die Methodenausführung wird fortgesetzt.

Umschreiben durch den Compiler

Wenn etwas Komplexes einfach aussieht, bedeutet das in der Regel, dass im Detail etwas Interessantes vor sich geht. Das ist bei asynchronen Methoden ganz gewiss der Fall. Durch die Einfachheit wird eine nette Abstraktion bereitgestellt, die sowohl das Schreiben als auch das Lesen von asynchronem Code sehr vereinfacht. Es ist nicht erforderlich, zu verstehen, was im Detail passiert. Aber wenn Sie es verstehen, werden Sie sicher besser asynchron programmieren können und eher in der Lage sein, das Feature optimal zu nutzen. Und wenn Sie das hier lesen, ist die Wahrscheinlichkeit groß, dass Sie auch einfach neugierig sind. Fangen wir also an: Wie funktionieren asynchrone Methoden und die darin enthaltenen await-Ausdrücke eigentlich wirklich?

Wenn der Visual Basic- oder C#-Compiler auf eine asynchrone Methode stößt, nimmt er sie während der Kompilierung auseinander: Die Diskontinuität der Methode wird von der zugrunde liegenden Laufzeitumgebung nicht direkt unterstützt und muss vom Compiler emuliert werden. Sie müssen die Methode also nicht mehr selbst in Einzelteile zerlegen, sondern der Compiler übernimmt das für Sie. Er macht das wahrscheinlich jedoch etwas anders, als Sie es manuell machen würden.

Der Compiler wandelt die asynchrone Methode in einen Zustandsautomaten um. Der Zustandsautomat verfolgt die aktuelle Position in der Ausführung sowie den lokalen Status. Dabei kann es sich um den Ausführungsstatus oder den angehaltenen Status handeln. Im Ausführungsstatus kann ein await-Ausdruck erreicht werden, der die Pausetaste drückt und die Ausführung anhält. Im angehaltenen Status kann die Wiedergabetaste gedrückt werden, damit die Ausführung fortgesetzt wird.

Der await-Ausdruck ist dafür zuständig, entsprechende Vorkehrungen zu treffen, damit nach Abschluss des erwarteten Tasks die Wiedergabetaste gedrückt wird. Bevor wir uns näher damit beschäftigen, schauen wir uns jedoch den Zustandsautomaten selbst und die Pause- und Wiedergabetasten an.

Taskgeneratoren

Asynchrone Methoden generieren Tasks. Genauer gesagt gibt eine asynchrone Methode eine Instanz des Typs Task oder Task<T> aus System.Threading.Tasks zurück. Diese Instanz wird automatisch generiert. Sie muss nicht (und kann nicht) durch den Benutzercode bereitgestellt werden. (Das ist eine kleine Lüge: Asynchrone Methoden können „void“ zurückgeben, aber das ignorieren wir jetzt erstmal.)

Aus der Perspektive des Compilers ist die Generierung von Tasks der einfache Teil. Er greift auf eine vom Framework bereitgestellte Angabe des Taskgenerators zurück, die in System.Runtime.CompilerServices zu finden ist. (Sie ist nicht für die direkte Verarbeitung durch Benutzer bestimmt.) Es gibt beispielsweise folgenden Typ:

public class AsyncTaskMethodBuilder<TResult>
{
  public Task<TResult> Task { get; }
  public void SetResult(TResult result);
  public void SetException(Exception exception);
}

Der Generator ermöglicht dem Compiler das Abrufen eines Tasks und dann die Ausführung des Tasks mit einem Ergebnis oder einer Ausnahme. Abbildung 1 zeigt, wie dieser Mechanismus für TryFetchAsync aussieht.

Abbildung 1 Erstellen eines Tasks

static Task<byte[]> TryFetchAsync(string url)
{
  var __builder = new AsyncTaskMethodBuilder<byte[]>();
  ...
  Action __moveNext = delegate
  {
    try
    {
      ...
      return;
      ...
      __builder.SetResult(…);
      ...
    }
    catch (Exception exception)
    {
      __builder.SetException(exception);
    }
  };
  __moveNext();
  return __builder.Task;
}

Schauen Sie genau hin:

  • Zuerst wird ein Generator erstellt.
  • Dann wird ein __moveNext-Delegat erstellt. Dieser Delegat ist die Wiedergabetaste. Wir nennen ihn Wiederaufnahmedelegat. Er enthält Folgendes:
    • Den ursprünglichen Code aus der asynchronen Methode (obwohl wir ihn bisher ignoriert haben).
    • return-Anweisungen, die das Drücken der Pausetaste darstellen.
    • Aufrufe, die den Generator mit einem erfolgreichen Ergebnis abschließen und den return-Anweisungen des ursprünglichen Codes entsprechen.
    • Den try/catch-Wrapper zum Abschluss des Generators mit Ausnahmen in Escapezeichen.
  • Die Wiedergabetaste wird gedrückt, und der Wiederaufnahmedelegat wird aufgerufen. Er wird ausgeführt, bis die Pausetaste gedrückt wird.
  • Der Task wird an den Aufrufer zurückgegeben.

Taskgeneratoren sind spezielle Hilfsprogrammtypen, die nur für die Verarbeitung durch Compiler bestimmt sind. Ihr Verhalten unterscheidet sich jedoch nicht sehr von dem Verhalten, das auftritt, wenn Sie die TaskCompletionSource-Typen der TPL (Task Parallel Library) direkt verwenden.

Bisher habe ich einen zurückzugebenden Task und eine Wiedergabetaste (Wiederaufnahmedelegat) erstellt, die gedrückt werden kann, wenn die Ausführung wieder aufgenommen werden soll. Ich muss mich noch darum kümmern, wie die Ausführung wieder aufgenommen wird und wie der await-Ausdruck die entsprechenden Vorkehrungen hierzu trifft. Bevor ich alles zusammenfüge, beschäftigen wir uns jedoch damit, wie Tasks verarbeitet werden.

„awaitables“ und „awaiters“

Wie Sie gesehen haben, können Tasks erwartet werden. In Visual Basic und C# können auch andere Dinge erwartet werden, solange sie erwartbar (awaitable) sind. Das bedeutet, sie müssen eine bestimmte Form aufweisen, anhand derer der await-Ausdruck kompiliert werden kann. Damit etwas erwartbar ist, muss es eine GetAwaiter-Methode aufweisen, die wiederum einen awaiter zurückgibt. Beispielsweise besitzt Task<TResult> eine GetAwaiter-Methode, die diesen Typ zurückgibt:

public struct TaskAwaiter<TResult>
{
  public bool IsCompleted { get; }
  public void OnCompleted(Action continuation);
  public TResult GetResult();
}

Die Elemente im „awaiter“ ermöglichen es dem Compiler, zu überprüfen, ob das „awaitable“ bereits abgeschlossen ist, einen entsprechenden Rückruf zu registrieren, wenn dies noch nicht der Fall ist, und das Ergebnis (oder die Ausnahme) abzurufen, wenn es abgeschlossen ist.

Wie soll ein await-Ausdruck nun vorgehen, um Pause und Wiederaufnahme rund um das „awaitable“ zu erzielen? Der await-Ausdruck in unserem TryFetchAsync-Beispiel würde folgendermaßen vorgehen:

 

__awaiter1 = client.DownloadDataTaskAsync(url).GetAwaiter();
  if (!__awaiter1.IsCompleted) {
    ... // Prepare for resumption at Resume1
    __awaiter1.OnCompleted(__moveNext);
    return; // Hit the "pause" button
  }
Resume1:
  ... __awaiter1.GetResult()) ...

Schauen Sie wieder, was passiert:

  • Ein „awaiter“ wird für den von DownloadDataTaskAsync zurückgegebenen Task abgerufen.
  • Wenn der „awaiter“ nicht abgeschlossen ist, wird die Wiedergabetaste (Wiederaufnahmedelegat) als Rückruf an den „awaiter“ übergeben.
  • Wenn der „awaiter“ die Ausführung wieder aufnimmt (bei Resume1), wird das Ergebnis abgerufen und in dem ihm nachfolgenden Code verwendet.

In der Regel ist das „awaitable“ ein Task oder Task<T>. Diese Typen, die bereits im Microsoft .NET Framework 4 vorhanden sind, wurden sehr genau für diese Rolle optimiert. Es gibt jedoch gute Gründe, auch andere erwartbare Typen („awaitable“) zuzulassen:

  • Überbrückung zu anderen Technologien: F# besitzt beispielsweise den Typ Async<T>, der im Wesentlichen Func<Task<T>> entspricht. Wenn Async<T> direkt aus Visual Basic und C# erwartet werden kann, trägt das zur Überbrückung zwischen asynchronem Code bei, der in den zwei Sprachen geschrieben ist. Auf ähnliche Weise weist F# eine Überbrückungsfunktion für den umgekehrten Weg auf – Tasks werden direkt in asynchronem F#-Code verarbeitet.
  • Spezielle Semantik wird implementiert: Die TPL selbst trägt einige einfache entsprechende Beispiele bei. Die statische Dienstprogrammmethode Task.Yield gibt beispielsweise ein „awaitable“ zurück, das (über IsCompleted) vorgibt, nicht abgeschlossen zu sein, aber sofort den an seine OnCompleted-Methode übergebenen Rückruf plant, als ob es abgeschlossen wäre. Sie können so die Planung erzwingen und die Compileroptimierung umgehen, es zu überspringen, wenn das Ergebnis bereits verfügbar ist. Dies können Sie sich zunutze machen, um Lücken im „Livecode“ zu schaffen und die Reaktionsfähigkeit von Code zu verbessern, der nicht inaktiv ist. Tasks selbst können keine Dinge darstellen, die abgeschlossen sind, aber vorgeben, es nicht zu sein. Daher wird hierzu ein besonderer erwartbarer Typ verwendet.

Bevor ich die erwartbare Implementierung eines Tasks näher erläutere, befassen wir uns noch weiter mit dem Umschreiben der asynchronen Methode durch den Compiler und arbeiten heraus, wie der Status der Methodenausführung verfolgt wird.

Der Zustandsautomat

Um alles zusammenzufügen, muss ich um die Generierung und Verarbeitung von Tasks herum einen Zustandsautomaten erstellen. Im Wesentlichen wird die gesamte Benutzerlogik aus der ursprünglichen Methode in den Wiederaufnahmendelegat gepackt, aber die Deklarationen der lokalen Variablen werden herausgehoben, sodass sie mehrere Aufrufe überleben können. Darüber hinaus wird eine Statusvariable zur Verfolgung des Fortschritts eingeführt. Die Benutzerlogik im Wiederaufnahmedelegat wird von einem Schalter umschlossen, der den Status überprüft und zum entsprechenden Label springt. Bei jedem Aufruf der Wiederaufnahme springt er zurück zu der Stelle, an der er das letzte Mal aufgehört hat. In Abbildung 2 wird alles zusammengefügt.

Abbildung 2 Erstellen eines Zustandsautomaten

static Task<byte[]> TryFetchAsync(string url)
{
  var __builder = new AsyncTaskMethodBuilder<byte[]>();
  int __state = 0;
  Action __moveNext = null;
  TaskAwaiter<byte[]> __awaiter1;
 
  WebClient client = null;
 
  __moveNext = delegate
  {
    try
    {
      if (__state == 1) goto Resume1;
      client = new WebClient();
      try
      {
        __awaiter1 = client.DownloadDataTaskAsync(url).GetAwaiter();
        if (!__awaiter1.IsCompleted) {
          __state = 1;
          __awaiter1.OnCompleted(__moveNext);
          return;
        }
        Resume1:
        __builder.SetResult(__awaiter1.GetResult());
      }
      catch (WebException) { }
      __builder.SetResult(null);
    }
    catch (Exception exception)
    {
      __builder.SetException(exception);
    }
  };
 
  __moveNext();
  return __builder.Task;
}

Das ist ganz schön viel! Bestimmt fragen Sie sich, warum dieser Code viel ausführlicher als die oben aufgeführte „asynchrone“ Version ist. Dafür gibt es einige gute Gründe, z. B. Effizienz (im Allgemeinen weniger Zuordnungen) und Allgemeingültigkeit (gilt für benutzerdefinierte „awaitables“, nicht nur Tasks). Der Hauptgrund ist jedoch folgender: Sie müssen die Benutzerlogik nicht auseinander nehmen. Sie erweitern sie einfach um einige Sprünge, Rückgaben und dergleichen.

Das Beispiel ist zu einfach, um es wirklich zu begründen. Aber die Logik einer Methode für jeden der kontinuierlichen Logikbestandteile zwischen den „awaits“ in semantisch gleichwertige diskrete Methoden umzuschreiben, ist eine sehr schwierige Angelegenheit. In je mehr Steuerungsstrukturen die „awaits“ verschachtelt sind, desto schlimmer wird es. Wenn die „awaits“ nicht nur von Schleifen mit continue- und break-Anweisungen, sondern auch von try-finally-Blöcken und sogar goto-Anweisungen umgeben sind, ist es äußerst schwierig, wenn nicht sogar unmöglich, eine Umschreibung mit hoher Wiedergabetreue zu erstellen.

Statt dies zu versuchen, kann der ursprüngliche Code des Benutzers einfach durch eine weitere Ebene Steuerungsstrukturen überlagert werden, die je nach Bedarf die Wiedergabe (mit bedingten Sprüngen) oder Pause (mit Rückgaben) ausführen. Bei Microsoft haben wir die Äquivalenz von asynchronen Methoden und ihren synchronen Gegenstücken systematisch getestet. Es hat sich bestätigt, dass dies ein sehr robuster Ansatz ist. Es gibt keine bessere Möglichkeit, synchrone Semantik im asynchronen Bereich zu erhalten, als den Code beizubehalten, der diese Semantik eigentlich beschreibt.

Das Kleingedruckte

Meine Beschreibung ist etwas idealisiert – für die Umschreibung sind noch ein paar mehr Tricks erforderlich. Vielleicht haben Sie sich das schon gedacht. Nachfolgend finden Sie noch einige Probleme, die der Compiler lösen muss:

goto-Anweisungen Das Umschreiben in Abbildung 2 führt eigentlich keine Kompilierung durch, da goto-Anweisungen (zumindest in C#) nicht zu den in verschachtelten Strukturen verborgenen Labels springen können. Das ist an sich kein Problem, da der Compiler in IL (Intermediate Language) und keinen Quellcode generiert. Er hat mit Verschachtelung nichts zu tun. Aber sogar IL lässt mitten in einem try-Block keine Sprünge zu, wie das in meinem Beispiel der Fall ist. Tatsächlich erfolgt ein Sprung zurück zum Anfang eines try-Blocks, der Block wird normal eingegeben. Dann wird umgeschaltet, und es erfolgt ein weiterer Sprung.

finally-Blöcke Bei der Rückgabe aus dem Wiederaufnahmedelegat aufgrund eines „await“, sollen die finally-Codetexte noch nicht ausgeführt werden. Das sollte erst geschehen, wenn die ursprünglichen return-Anweisungen aus dem Benutzercode ausgeführt werden. Sie steuern das durch Generierung eines booleschen Flags, das signalisiert, ob die endgültigen Codetexte ausgeführt werden sollen, und durch deren Erweiterung, um dies zu überprüfen.

Auswertungsreihenfolge Ein await-Ausdruck ist nicht notwendigerweise das erste Argument für einen Methode oder einen Operator. Er kann in der Mitte vorkommen. Damit die Auswertungsreihenfolge beibehalten wird, müssen alle vorherigen Argumente vor dem await-Ausdruck ausgewertet werden. Überraschenderweise werden sie gespeichert und nach dem await-Ausdruck erneut abgerufen.

Zusätzlich gibt es noch einige Einschränkungen, die nicht umgangen werden können. await-Ausdrücke sind beispielsweise innerhalb eines catch- oder finally-Blocks nicht zulässig, da keine zuverlässige Methode bekannt ist, um nach dem await-Ausdruck wieder den richtigen Ausnahmekontext einzurichten.

Der „Task Awaiter“

Der „awaiter“, den der vom Compiler generierte Code zur Implementierung des await-Ausdrucks verwendet, hat hinsichtlich der Planung des Wiederaufnahmedelegats relativ viel Freiheit, nämlich den Rest der asynchronen Methode. Das Szenario muss jedoch sehr fortgeschritten sein, damit die Notwendigkeit auftritt, einen eigenen „awaiter“ zu implementieren. Die Tasks selbst sind bei der Planung sehr flexibel, denn sie berücksichtigen das Planungskonzept eines Kontexts, der selbst austauschbar ist.

Der Planungskontext ist eines dieser Konzepte, die wahrscheinlich etwas besser aussähen, wenn sie von Beginn an beim Entwurf berücksichtigt worden wären. So handelt es sich um eine Mischung aus einigen bestehenden Konzepten, die wir mit dem Versuch, ein einheitliches Konzept einzuführen, noch etwas mehr vermurkst haben. Schauen wir uns die Idee auf der konzeptionellen Ebene an, und beschäftigen wir uns danach mit der Umsetzung.

Die Philosophie, die der Planung asynchroner Rückrufe für erwartete Tasks zugrunde liegt, besteht darin, dass die Ausführung dort fortgesetzt wird, „wo zuvor aufgehört wurde“. „wo“ ist dabei ein beliebiger Wert. Dieses „wo“ bezeichne ich als Planungskontext. Der Planungskontext ist ein Thread-affines Konzept. Jeder Thread besitzt einen Planungskontext (maximal). Wenn die Ausführung in einem Thread erfolgt, können Sie nach dem Planungskontext fragen, indem er ausgeführt wird. Und in einem Planungskontext können Sie Ausführungen im Thread planen.

Eine asynchrone Methode sollte folgendermaßen vorgehen, wenn sie einen Task erwartet:

  • Bei Unterbrechung: Den Thread, in dem sie ausgeführt wird, nach dem Planungskontext fragen
  • Bei Wiederaufnahme: Den Wiederaufnahmedelegat wieder in diesem Planungskontext planen

Warum ist das wichtig? Betrachten wird den UI-Thread. Er besitzt einen eigenen Planungskontext, der neue Arbeit plant, indem er sie durch die Meldungswarteschlange zurück in den UI-Thread sendet. Wenn die Ausführung also im UI-Thread erfolgt, ein Task erwartet wird und das Ergebnis des Tasks fertig ist, wird der Rest der asynchronen Methode wieder im UI-Thread ausgeführt. Alle Dinge, die nur im UI-Thread erfolgen können (Bearbeitung der UI), können noch nach dem await-Ausdruck erfolgen. Es ereignet sich kein merkwürdiger „Thread-Sprung“ mitten im Code.

Andere Planungskontexte sind Multithread-Kontexte. Eigens der standardmäßige Threadpool wird als einzelner Planungskontext dargestellt. Wenn neue Arbeit für ihn geplant wird, kann sie von einem beliebigen Thread des Pools ausgeführt werden. Eine asynchrone Methode, deren Ausführung im Threadpool beginnt, wird auch weiterhin dort ausgeführt. Es kann allerdings sein, dass sie zwischen verschiedenen spezifischen Threads wechselt.

In der Praxis gibt es kein einiges Konzept, das dem Planungskontext entspricht. Man kann sagen, dass der SynchronizationContext eines Threads als sein Planungskontext dient. Wenn ein Thread einen davon besitzt (ein vorhandenes Konzept, das vom Benutzer implementiert werden kann), wird er verwendet. Besitzt ein Thread keinen, wird der TaskScheduler des Threads verwendet (ein ähnliches Konzept, das durch die TPL eingeführt wird). Besitzt der Thread auch keinen TaskScheduler, wird der standardmäßige TaskScheduler verwendet, der auch die Wiederaufnahmen für den standardmäßigen Threadpool plant.

Natürlich führt diese ganze Planungssache zu Leistungseinbußen. In Benutzerszenarien zahlt sie sich in der Regel aber aus, und die Nachteile sind zu vernachlässigen: Den UI-Code in verwaltbare Stücke einzuteilen und ihn über das Nachrichtensystem zu senden, wenn die erwarteten Ergebnisse verfügbar sind, ist normalerweise genau das Richtige.

Manchmal aber wird es zu detailliert. Das gilt insbesondere für Bibliothekscode. Schauen Sie sich Folgendes an:

async Task<int> GetAreaAsync()
{
  return await GetXAsync() * await GetYAsync();
}

Hier erfolgt zweimal die Rückwärtsplanung im Planungskontext (nach jedem „await“), nur um im „richtigen“ Thread eine Multiplikation durchzuführen. Aber wenn kümmert es, in welchem Thread multipliziert wird? Das ist wahrscheinlich sinnlos (bei häufiger Verwendung), und es gibt Tricks, um dies zu vermeiden: Im Wesentlichen können Sie den erwarteten Task in ein „awaitable“ einschließen, das kein Task ist und das weiß, wie das Rückwärtsplanungsverhalten deaktiviert wird, und die Wiederaufnahme in dem Thread ausführen, der den Task abschließt. So werden der Kontextwechsel und die Planungsverzögerung vermieden:

async Task<int> GetAreaAsync()
{
  return await GetXAsync().ConfigureAwait(continueOnCapturedContext: false)
    * await GetYAsync().ConfigureAwait(continueOnCapturedContext: false);
}

Nicht so schön, aber ein guter Trick, der in Bibliothekscode angewendet werden kann, der sich als Engpass für die Planung erweist.

Resümee

Jetzt sollten Sie mit den Grundlagen der asynchronen Methoden vertraut sein. Nachfolgend finden Sie die wahrscheinlich hilfreichsten Aspekte, die Sie sich merken sollten:

  • Der Compiler erhält die Bedeutung von Steuerungsstrukturen, indem er tatsächlich die Steuerungsstrukturen erhält.
  • Asynchrone Methoden planen keine neuen Threads – sie ermöglichen das Multiplexen vorhandener Threads.
  • Wenn Tasks erwartet werden, wird die Ausführung dort fortgesetzt, „wo zuvor aufgehört wurde“. Dort wird eine geeignete Definition bereitgestellt.

Wenn es Ihnen wie mir geht, haben Sie abwechselnd den Artikel gelesen und Code eingegeben. Sie haben mehrere Ablaufsteuerungen, nämlich Lesen und Codieren, in demselben Thread gemultiplext: in Ihnen selbst. Genau das ermöglichen Ihnen asynchrone Methoden.

Mads Torgersen ist leitender Programmmanager im Team für C# und Visual Basic bei Microsoft.

Unser Dank gilt dem folgenden technischen Experten für die Durchsicht dieses Artikels: Stephen Toub