Asynchrone Programmierung

Einfachere asynchrone Programmierung mit der neuen Visual Studio Async CTP

Eric Lippert

 

Stellen Sie sich eine Welt vor, in der Menschen wie Computerprogramme agieren:

 

void ServeBreakfast(Customer diner)
{
  var order = ObtainOrder(diner);
  var ingredients = ObtainIngredients(order);
  var recipe = ObtainRecipe(order);
  var meal = recipe.Prepare(ingredients);
  diner.Give(meal);
}

Dabei könnte jede Unterroutine natürlich weiter aufgeschlüsselt werden. Zur Zubereitung einer Mahlzeit gehörten dann im Einzelnen das Erwärmen von Pfannen, das Braten von Omeletts und das Toasten von Brot. Würden wir Menschen diese Aufgaben wie Computerprogramme erledigen, würden wir alle Aufgaben der Reihenfolge nach in einer hierarchischen Checkliste notieren und peinlich genau darauf achten, dass eine Aufgabe erst vollständig abgeschlossen ist, ehe die nächste begonnen wird.

Ein auf Unterroutinen basierender Ansatz scheint vernünftig. Sie fangen natürlich nicht an, Eier zu braten, bevor Sie den Auftrag dazu haben. In der Realität ist das aber eher unpraktisch, und es würde so aussehen, als würden Sie (die Anwendung) nicht angemessen reagieren. Es ist unpraktisch, weil Sie das Brot natürlich schon toasten möchten, während die Eier gebraten werden. Sonst sind die Eier kalt, wenn das Brot fertig ist. Und es sieht so aus, als würden Sie nicht angemessen reagieren, denn wenn ein neuer Gast hereinkommt, während die aktuelle Bestellung noch in Arbeit ist, soll die Bestellung natürlich trotzdem entgegengenommen werden. Sie möchten nicht, dass der Gast wartet, bis der vorherige Gast sein Frühstück serviert bekommen hat. Ein Server bzw. eine Servicekraft, der bzw. die strikt nach Checkliste arbeitet, kann nicht zeitgerecht auf unerwartete Ereignisse reagieren.

Lösung eins: Engagieren Sie mehr Personal, indem Sie mehr Threads erstellen

Das Beispiel einer Frühstückszubereitung mag Ihnen seltsam vorkommen, verdeutlicht aber gut die Realität. Jedes Mal, wenn Sie die Steuerung an eine zeitintensive Unterroutine im Benutzeroberflächen-Thread übergeben, reagiert die Benutzeroberfläche erst wieder, wenn die aktuelle Unterroutine abgeschlossen ist. Wie könnte es anders laufen? Anwendungen reagieren auf Ereignisse der Benutzeroberfläche, indem sie Code im Benutzeroberflächen-Thread ausführen. Dieser Thread ist mit der Ausführung anderer Aufgaben völlig ausgefüllt. Die in die Warteschlange eingereihten Befehle des frustrierten Benutzers werden erst verarbeitet, wenn alle Aufgaben auf der Liste erledigt sind. Zur Lösung des Problems wird dann in der Regel die Methode der Parallelitäteingesetzt, damit mehrere Aufgaben gleichzeitig erledigt werden können. Wenn sich die beiden Threads auf unterschiedlichen Prozessoren befinden, dann können sie tatsächlich auch zur selben Zeit ausgeführt werden. In einer Welt jedoch, in der es mehr Threads als zuweisbare Prozessoren gibt, simuliert das Betriebssystem eine Parallelität, indem periodisch ein Zeitintervall für jeden Thread eingeplant wird, um einen Prozessor zu steuern.

Mit einem Threadpool könnte das Problem eventuell gelöst werden. Jedem neuen Client wird ein Thread zugewiesen, um die Anforderung zu verarbeiten. In unserem Beispiel könnten Sie also eine Gruppe von Servicekräften einstellen. Kommt eine neue Bestellung herein, wird diese sofort einer Servicekraft zugewiesen. Jede Servicekraft arbeitet dann unabhängig, nimmt die Bestellung entgegen, stellt die Zutaten zusammen, bereitet das Essen zu und serviert dieses anschließend.

Das Problem bei diesem Ansatz ist, dass Benutzeroberflächenereignisse normalerweise im selben Thread landen und dort auch komplett verarbeitet werden möchten. Die meisten Benutzeroberflächenkomponenten stellen Anforderungen im Benutzeroberflächen-Thread und erwarten dort auch den vollständigen Kommunikationsablauf. Das Zuweisen eines neuen Threads zu jeder Aufgabe, die mit der Benutzeroberfläche verbunden ist, wird vermutlich nicht funktionieren.

Ein einzelner Thread im Vordergrund für auftretende Benutzeroberflächenereignisse, der nichts anderes macht, als Aufträge entgegenzunehmen, um diese dann an Arbeitsthreads im Hintergrund weiterzureichen, wäre eine mögliche Lösung. In unserem Beispiel gibt es nur eine Servicekraft, die die Kunden bedient, sowie eine Küche voll mit Köchen, die die erforderlichen Arbeiten durchführen. Der Benutzeroberflächen-Thread und die Arbeitsthreads sind verantwortlich für die Koordination der Kommunikation. Die Köche sprechen zwar niemals direkt mit den Servicekräften, das Essen wird aber irgendwie trotzdem serviert.

Dadurch wird sicherlich das Problem der zeitgerechten Reaktion auf Benutzeroberflächenereignisse gelöst, das der Effizienz allerdings nicht. Der Code, der im Arbeitsthread ausgeführt wird, wartet immer noch darauf, dass die Eier fertig sind, ehe das Brot in den Toaster gesteckt wird. Vielleicht ist noch mehr Parallelität die Lösung: Sie könnten zwei Köche pro Bestellung beschäftigen. Einen für die Eier und einen für den Toast. Das könnte allerdings sehr teuer werden. Wie viele Köche sind erforderlich, und was passiert, wenn diese ihre Arbeit koordinieren müssen?

Eine Parallelität dieser Art bringt altbekannte Probleme mit sich. Zum einen sind Threads bekannterweise echte Schwergewichte, da sie standardmäßig Millionen von Bytes an virtuellem Speicher für den Stack benötigen sowie viele andere Ressourcen. Zum anderen haben Benutzeroberflächenobjekte oftmals eine große Affinität zum Thread der Benutzeroberfläche und können nicht von Arbeitsthreads aufgerufen werden. Die beiden Threads müssen eine komplexe Vereinbarung treffen, sodass der Benutzeroberflächen-Thread erforderliche Informationen zu den Benutzeroberflächenelementen an die Arbeitskräfte senden und die Arbeitskräfte Aktualisierungen zurück an den Benutzeroberflächen-Thread und nicht direkt an die Benutzeroberflächenelemente übergeben können. Solche Vereinbarungen sind schwer zu kodieren und ziehen Bedingungen, Deadlocks und andere Threadingprobleme nach sich. Des Weiteren sind viele der Annahmen, von denen wir in einer Singlethread-Welt ausgehen, beispielsweise das Lesen und Schreiben von Speicherdaten in vorhersehbarer und konsistenter Reihenfolge, oftmals nicht mehr gültig. Dadurch kommt es zu schwerwiegenden Fehlern, die kaum zu reproduzieren sind.

Es scheint irgendwie unlogisch, dass diese komplexe threadbasierte Parallelität wirklich erforderlich sein soll, um einfache und effiziente Programme zu schreiben, die reaktionsfähig bleiben. In der Realität können Menschen komplexe Probleme lösen und bleiben trotzdem ansprechbar für aktuelle Ereignisse. Im realen Leben müssen Sie nicht einen Kellner pro Tisch und zwei Köche pro Bestellung zuweisen, damit verschiedene Aufgaben zur selben Zeit erledigt werden. Threading ist also nicht die Lösung, es sei denn, Sie möchten unzählige Köche beschäftigen. Es muss also eine einfachere Lösung mit weniger Parallelität her.

Lösung zwei: Entwickeln Sie Aufmerksamkeitsstörungen mit DoEvents

Eine bekannte Lösung des Problems einer nicht reagierenden Benutzeroberfläche während zeitintensiver Vorgänge ohne Gleichzeitigkeit ist das freie Verteilen der magischen Worte „Application.DoEvents“ um das Programm herum. Und zwar so lange, bis das Problem verschwunden ist. Dies ist sicherlich eine pragmatische Lösung, ausgetüftelt ist sie jedoch nicht.

void ServeBreakfast(Customer diner)
{
  var order = ObtainOrder(diner);
  Application.DoEvents();
  var ingredients = ObtainIngredients(order);
  Application.DoEvents();
  var recipe = ObtainRecipe(order);
  Application.DoEvents();
  var meal = recipe.Prepare(ingredients);
  Application.DoEvents();
  diner.Give(meal);
}

Bei den DoEvents geht es im Wesentlichen um Folgendes: „Schau nach, ob etwas Wichtiges passiert ist, während ich noch mit der letzten Aufgabe beschäftigt war. Ist etwas geschehen, auf das ich reagieren muss, merke dir, was ich gerade getan habe, verarbeite die neue Situation und kehre dann zum Ausgangspunkt zurück“. Das Programm leidet quasi an einer Aufmerksamkeitsstörung: Alles Neue bekommt sofort Aufmerksamkeit. Scheint eine plausible Lösung zur Verbesserung der Reaktionsfähigkeit zu sein. Und manchmal ist es das auch. Allerdings birgt dieser Ansatz viele mögliche Probleme.

Erstens funktionieren DoEvents am besten, wenn die Verzögerung aufgrund einer Schleife erfolgt, die mehrmals ausgeführt werden muss, bei der die einzelnen Durchführungen aber nicht lange dauern. Durch das Prüfen auf ausstehende Ereignisse zwischen den Durchführungen erhalten Sie die Reaktionsfähigkeit, auch wenn die Ausführung der gesamten Schleife viel Zeit in Anspruch nimmt. Dieses Muster führt allerdings in der Regel nicht zu einem Problem mit der Reaktionsfähigkeit. Viel öfter wird das Problem durch einen von Natur aus zeitintensiven Vorgang verursacht, beispielsweise bei dem Versuch, synchron über ein Netzwerk mit hoher Latenz auf eine Datei zuzugreifen. In unserem Beispiel ist womöglich das Zubereiten der Mahlzeit der zeitintensive Vorgang, und es gibt keine Stelle, an der die DoEvents etwas nützen würden. Oder es gibt eine Stelle, aber diese befindet sich in einer Methode, für die Sie nicht den Quellcode besitzen.

Zweitens versucht das Programm beim Aufruf von DoEvents, alle kürzlich passierten Ereignisse zu verarbeiten, ehe die Arbeit an früheren Ereignissen abgeschlossen ist. Stellen Sie sich vor, niemand bekommt sein Essen, bis alle nachträglich eingetroffenen Gäste ihre Mahlzeit erhalten haben! Reißt der Kundenstrom nicht ab, erhält der erste Gast vermutlich nie sein Essen und bleibt hungrig. Tatsächlich kann es sogar sein, dass kein Gast sein Essen bekommt. Das Abschließen der Arbeiten, die aufgrund der früheren Ereignisse notwendig sind, wird möglicherweise weit in die Zukunft verschoben, da das Bedienen neuer Ereignisse kontinuierlich die alten Arbeiten unterbricht.

Drittens bergen DoEvents die reale Gefahr der Eintrittsinvarianz. Denn während ein Kunde bedient wird, prüfen Sie, ob zwischenzeitlich wichtige Ereignisse (der Benutzeroberfläche) eingetreten sind und servieren möglicherweise das gleiche Essen zweimal. Die meisten Entwickler schreiben ihren Code nicht so, dass diese Art von Eintrittsinvarianz entdeckt wird. So kann es zu unerwarteten Programmstatus kommen, beispielsweise, wenn sich ein Algorithmus, der nicht rekursiv sein soll, plötzlich selbst über DoEvents aufruft.

Kurz gesagt, sollten DoEvents nur in einfachen Fällen zur Lösung eines Reaktionsproblems eingesetzt werden. Für die Steuerung der Reaktionsfähigkeit einer Benutzeroberfläche in komplexen Programmen sind DoEvents keine gute Idee.

Lösung drei: Stellen Sie die Checkliste mit Rückrufen auf den Kopf

DoEvents basieren auf einer Nicht-Gleichzeitigkeit. Diese Eigenschaft ist zwar attraktiv, aber trotzdem nicht die passende Lösung für ein komplexes Programm. Besser ist es, die Elemente der Checkliste in kleine, kurze Aufgaben zu unterteilen. Dann kann jede Aufgabe so schnell ausgeführt werden, dass die Anwendung noch auf Ereignisse reagieren kann.

Die Idee ist nicht neu. Durch die Unterteilung eines komplexen Programms erhalten wir zunächst einmal Unterroutinen. Der Knackpunkt besteht darin, dass, anstatt stoisch eine Checkliste abzuarbeiten, um festzustellen, was bereits erledigt wurde und was noch zu erledigen ist, und die Steuerung erst dann wieder an den Aufrufer zurückzugeben, wenn alles erledigt ist, jede neue Aufgabe die Liste der Arbeiten erhält, die anschließend zu erledigen sind. Die Arbeit, die durchzuführen ist, nachdem eine bestimmte Aufgabe erledigt wurde, wird als Fortsetzung der Aufgabe bezeichnet.

Wurde eine Aufgabe erledigt, kann sich das Programm der Fortsetzung widmen und die entsprechende Aufgabe an der Stelle auch gleich zu Ende führen. Die Fortsetzung kann auch für eine spätere Ausführung eingeplant werden. Benötigt die Fortsetzung Informationen von der vorherigen Aufgabe, kann diese die Informationen als Argument an den Aufruf übergeben, der die Fortsetzung aufgerufen hat.

Bei diesem Ansatz wird die Arbeit im Wesentlichen in kleine Arbeitsschritte unterteilt, die schnell ausgeführt werden können. Das System scheint reaktionsfähig, da ausstehende Ereignisse zwischen der Durchführung zweier Arbeitsschritte entdeckt und verarbeitet werden können. Da aber alle Aktivitäten, die mit den neuen Ereignissen verbunden sind, ebenfalls weiter unterteilt und in Warteschlangen eingereiht werden können, kommt es hier nicht zu einer „Hungersnot“. Neue Aufgaben führen keineswegs dazu, dass alte nicht erledigt werden. Neue zeitintensive Aufgaben werden nicht sofort verarbeitet, sondern auf später verschoben.

Die Idee ist gut, die Umsetzung jedoch nicht einfach. Die Schwierigkeit besteht darin, jedem kleinen Arbeitsschritt das Prinzip der Fortsetzung klar zu machen, damit er weiß, was als Nächstes getan werden muss.

Bei einem herkömmlichen asynchronen Code geschieht dies in der Regel durch das Registrieren einer Callback-Funktion. Angenommen, wir haben eine asynchrone Version vom Typ „Prepare“ mit einer Funktion für den Rückruf, die den nächsten Schritt festlegt – in unserem Fall das Servieren des Essens.

void ServeBreakfast(Diner diner)
{
  var order = ObtainOrder(diner);
  var ingredients = ObtainIngredients(order);
  var recipe = ObtainRecipe(order);
  recipe.PrepareAsync(ingredients, meal =>
    {
      diner.Give(meal);
    });
}

Nun wird ServeBreakfast sofort zurückgegeben, nachdem PrepareAsync zurückgegeben wurde. Der Code, der ServeBreakfast aufgerufen hat, kann dann andere Ereignisse verarbeiten. PrepareAsync führt selbst keine Arbeiten durch, sondern erledigt nur schnell, was nötig ist, um sicherzustellen, dass die Mahlzeit in Zukunft zubereitet wird. Darüber hinaus stellt PrepareAsync sicher, dass die Callback-Methode mit dem zubereiteten Mahl aufgerufen wird, da das Argument einige Zeit nach dem Beenden der Essensvorbereitung abgeschlossen ist. Somit wird das Essen letztendlich serviert. Findet zwischen der Zubereitung und dem Servieren ein Ereignis statt, dem Aufmerksamkeit geschenkt werden muss, muss eventuell etwas auf das Essen gewartet werden.

Beachten Sie, dass hierbei kein zweiter Thread erforderlich ist. Möglicherweise verursacht PrepareAsync, dass die Essenszubereitung in einem separaten Thread erfolgt oder dass kleinere Zubereitungsaufgaben im Benutzeroberflächen-Thread für eine spätere Ausführung in die Warteschlange eingereiht werden. Das ist aber nicht von Bedeutung. Wir wissen nur, dass PrepareAsync irgendwie zwei Dinge garantiert: Das Essen wird so zubereitet, dass der Benutzeroberflächen-Thread nicht durch einen Vorgang mit hoher Latenz blockiert wird, und der Rückruf wird nach Zubereitung des bestellten Essens aufgerufen.

Mal angenommen, eine der Methoden für die Entgegennahme der Bestellung, der Zutaten, des Rezepts oder der Zubereitung des Essens ist die Methode, die die Benutzeroberfläche verlangsamt. Zur Lösung des Problems benötigen wir eine asynchrone Version der beiden Methoden. Wie würde das entsprechende Programm aussehen? Vergessen Sie nicht, dass jede Methode einen Rückruf benötigt, der ihr mitteilt, wann ein Arbeitsschritt abgeschlossen ist.

void ServeBreakfast(Diner diner)
{
  ObtainOrderAsync(diner, order =>
  {
    ObtainIngredientsAsync(order, ingredients =>
    {
      ObtainRecipeAsync(order, recipe =>
      {
        recipe.PrepareAsync(ingredients, meal =>
        {
          diner.Give(meal);
        })})})});
}

Das klingt nach einem großen Durcheinander, ist aber nichts im Vergleich zu Programmen, die mit einer rückrufbasierten Asynchronie neu geschrieben werden. Überlegen Sie mal, wie Sie eine Schleife asynchron machen würden, oder wie Sie mit Ausnahmen, try/finally-Blocks oder anderen anspruchsvollen Formen der Ablaufsteuerung umgehen würden. Am Ende hätten Sie Ihr Programm vermutlich auf den Kopf gestellt. Der Code würde sich wahrscheinlich darauf konzentrieren, wie all die Rückrufe miteinander verbunden sind und nicht darauf, wie der logische Workflow des Programms aussehen sollte.

Lösung vier: Lassen Sie den Compiler das Problem mit einer aufgabenbasierten Asynchronie lösen

Bei einer rückrufbasierten Asynchronie bleibt die Reaktionsfähigkeit des Benutzeroberflächen-Threads erhalten. Darüber hinaus wird nur wenig Zeit verschwendet, indem synchron auf den Abschluss einer zeitintensiven Aufgabe gewartet wird. Allerdings scheint das Medikament schlimmer zu sein als die Krankheit. Der Preis, den Sie für Leistungsfähigkeit und Reaktionsfähigkeit zahlen, ist Folgender: Sie müssen einen Code schreiben, der sich darauf konzentriert, wie der Mechanismus der Asynchronie funktioniert. Dabei geraten Bedeutung und Zweck des Codes in den Hintergrund.

Mit den kommenden C#- und Visual Basic-Versionen hingegen können Sie einen Code schreiben, der sich auf den Sinn und Zweck konzentriert. Gleichzeitig können Sie Hinweise für die Compiler geben, damit im Hintergrund die notwendigen Mechanismen erstellt werden. Die Lösung besteht aus zwei Teilen: einem Typsystem-Teil und einem Sprach-Teil

In der CLR 4-Version wurde der Typ Task<T> – der leistungsfähige Typ der Task Parallel Library (TPL) – definiert, um das Konzept „Arbeit, die in Zukunft als Ergebnis einen Typ T produziert“ wiederzugeben. Das Konzept „Arbeit, die in der Zukunft abgeschlossen wird, aber kein Ergebnis liefert“ wird durch den nicht generischen Aufgabentypen wiedergegeben.

Genau genommen ist die Frage, wie das Ergebnis vom Typ Task<T> zukünftig produziert wird, eine Frage der Implementierung einer bestimmten Aufgabe. Möglicherweise wird die Arbeit komplett auf einen anderen Computer, einen anderen Prozess auf dem Computer oder auf einen anderen Thread übertragen. Möglich ist auch, dass die Arbeit nur darin besteht, ein zuvor zwischengespeichertes Ergebnis zu lesen, auf das problemlos über den aktuellen Thread zugegriffen werden kann. TLP-Aufgaben werden in der Regel von einem Threadpool im aktuellen Prozess in Arbeitsthreads ausgelagert. Dieses Implementierungsdetail ist jedoch kein wesentlicher Bestandteil von Task<T>. Vielmehr kann dieser Typ alle Operationen mit hoher Latenz darstellen, die ein T generieren.

Das neue await-Schlüsselwort ist Teil des sprachlichen Lösungsansatzes. Ein regulärer Methodenaufruf bedeutet in etwa: „Denk dran, was du gerade tust, führe die Methode komplett durch und kehre dann zum Ausgangspunkt zurück. Jetzt kennst du das Ergebnis der Methode.“ Ein await-Ausdruck hingegen bedeutet in etwa: „Werte die Methode aus, um ein Objekt zu erhalten, das die Arbeit wiedergibt, durch die in Zukunft das Ergebnis produziert wird. Erkläre den Rest der aktuellen Methode zum Rückruf, der mit der Fortsetzung dieser Aufgabe verbunden ist. Nachdem die Aufgabe erstellt und der Rückruf angemeldet ist, übergebe die Steuerung sofort zurück an meinen Aufrufer.”

Mit der neuen Syntax sieht unser kleines Beispiel schon viel besser aus:

async void ServeBreakfast(Diner diner)
{
  var order = await ObtainOrderAsync(diner);
  var ingredients = await ObtainIngredientsAsync(order);
  var recipe = await ObtainRecipeAsync(order);
  var meal = await recipe.PrepareAsync(ingredients);
  diner.Give(meal);
}

Bei dieser Version gibt jede asynchrone Version einen Task<Order>, Task<List<Ingredient>> und so weiter zurück. Bei jedem await meldet die aktuell ausführende Methode den Rest der Methode als die Aktion an, die auszuführen ist, wenn die aktuelle Aufgabe erledigt ist, und kehrt dann sofort zum Ausgangspunkt zurück. Irgendwie schließt sich jede Aufgabe selbst ab, entweder weil sie als auszuführendes Ereignis im aktuellen Thread eingeplant wurde oder weil ein E/A-Abschlussthread oder Arbeitsthread verwendet wurde, und führt die Aufgabe dann am Ausgangspunkt zu Ende durch.

Beachten Sie, dass die Methode nun mit dem neuen async-Schlüsselwort gekennzeichnet ist. Diese Kennzeichnung ist lediglich ein Indikator für den Compiler. Er weiß dadurch, dass im Methodenkontext das await-Keyword als der Punkt zu behandeln ist, an dem der Workflow die Steuerung an den Aufrufer zurückgibt und diese wieder übernimmt, wenn die verknüpfte Aufgabe erledigt ist. Beachten Sie auch, dass bei den Beispielen in diesem Artikel C#-Code verwendet wird. Visual Basic bekommt eine ähnliche Funktion mit ähnlicher Syntax. Das Design dieser Features in C# und Visual Basic wurde sehr durch asynchrone Workflows in F# beeinflusst, einem Feature, das seit einiger Zeit in F# verfügbar ist.

Weiterführende Informationen

Diese kurze Einleitung beschränkt sich auf grundlegende Informationen. Es wird nur an der Oberfläche des neuen asynchronen Features in C# und Visual Basic gekratzt. Weitere Hintergrundfunktionen und Daten zu den Leistungsmerkmalen des asynchronen Codes erhalten Sie in den begleitenden Artikeln von meinen Kollegen Mads Torgersen und Stephen Toub in dieser Ausgabe.

Zugriff auf eine Vorabversion des Features mit Beispielen, Whitepapers und einem Community-Forum für Fragen, Diskussionen und konstruktives Feedback erhalten Sie unter msdn.com/async. Die unterstützenden Sprachfunktionen und Bibliotheken sind noch in der Entwicklung. Deshalb freut sich das Designteam über möglichst viel Feedback.

Eric Lippert ist Entwickler im C#-Compilerteam von Microsoft.

Mein Dank gilt den folgenden technischen Experten für die Durchsicht dieses Artikels: *Mads Torgersen, *Stephen Toub *und *Lucian Wischik.