Warum sollten nicht alle Funktionen standardmäßig asynchron sein?

107

Das asynchrone Wartemuster von .net 4.5 ändert das Paradigma. Es ist fast zu schön um wahr zu sein.

Ich habe einen E / A-lastigen Code auf async-await portiert, da das Blockieren der Vergangenheit angehört.

Nicht wenige Leute vergleichen asynchrones Warten mit einem Zombie-Befall und ich fand es ziemlich genau. Async-Code mag anderen Async-Code (Sie benötigen eine Async-Funktion, um auf eine Async-Funktion zu warten). So werden immer mehr Funktionen asynchron und dies wächst in Ihrer Codebasis weiter.

Das Ändern von Funktionen in Async ist eine sich wiederholende und einfallslose Arbeit. Wenn Sie ein asyncSchlüsselwort in die Deklaration einfügen, den Rückgabewert mit Task<>einschließen, sind Sie ziemlich fertig. Es ist ziemlich beunruhigend, wie einfach der gesamte Prozess ist, und ziemlich bald wird ein Textersetzungsskript den größten Teil der "Portierung" für mich automatisieren.

Und jetzt die Frage: Wenn mein gesamter Code langsam asynchron wird, warum nicht einfach alles standardmäßig asynchron machen?

Der offensichtliche Grund, den ich annehme, ist die Leistung. Async-await hat einen Overhead und Code, der nicht asynchron sein muss, vorzugsweise nicht. Wenn jedoch die Leistung das einzige Problem ist, können einige clevere Optimierungen den Overhead automatisch entfernen, wenn er nicht benötigt wird. Ich habe über die "Fast Path" -Optimierung gelesen , und es scheint mir, dass sie sich allein um das meiste kümmern sollte.

Vielleicht ist dies vergleichbar mit dem Paradigmenwechsel der Müllsammler. In den frühen GC-Tagen war es definitiv effizienter, das eigene Gedächtnis freizugeben. Aber die Massen entschieden sich immer noch für die automatische Erfassung zugunsten eines sichereren, einfacheren Codes, der möglicherweise weniger effizient ist (und selbst das ist wohl nicht mehr wahr). Vielleicht sollte dies hier der Fall sein? Warum sollten nicht alle Funktionen asynchron sein?

talkol
quelle
8
Geben Sie dem C # -Team die Gutschrift für das Markieren der Karte. Wie vor Hunderten von Jahren "liegen hier Drachen". Sie könnten ein Schiff ausrüsten und dorthin gehen, wahrscheinlich werden Sie es mit der Sonne und dem Wind im Rücken überleben. Und manchmal nicht, sie kehrten nie zurück. Ähnlich wie bei async / await ist SO mit Fragen von Benutzern gefüllt, die nicht verstanden haben, wie sie von der Karte gekommen sind. Obwohl sie eine ziemlich gute Warnung bekommen haben. Jetzt ist es ihr Problem, nicht das Problem des C # -Teams. Sie markierten die Drachen.
Hans Passant
1
@Sayse auch wenn Sie die Unterscheidung zwischen Synchronisierungs- und Asynchronisierungsfunktionen aufheben, sind synchron implementierte Aufruffunktionen immer noch synchron (wie in Ihrem WriteLine-Beispiel)
talkol
2
"Wrap the return value by Task <>" Sofern Ihre Methode nicht sein muss async(um einen Vertrag zu erfüllen), ist dies wahrscheinlich eine schlechte Idee. Sie haben die Nachteile von Async (erhöhte Kosten für Methodenaufrufe; die Notwendigkeit, awaitden aufrufenden Code zu verwenden), aber keinen der Vorteile.
Svick
1
Dies ist eine wirklich interessante Frage, die aber möglicherweise nicht gut zu SO passt. Wir könnten erwägen, es auf Programmierer zu migrieren.
Eric Lippert
1
Vielleicht fehlt mir etwas, aber ich habe genau die gleiche Frage. Wenn async / await immer paarweise vorkommt und der Code noch warten muss, bis die Ausführung abgeschlossen ist, warum nicht einfach das vorhandene .NET Framework aktualisieren und die Methoden asynchronisieren - standardmäßig asynchron, ohne zusätzliche Schlüsselwörter zu erstellen? Die Sprache wird bereits zu dem, was sie entkommen soll - Schlüsselwort Spaghetti. Ich denke, sie hätten damit aufhören sollen, nachdem das "var" vorgeschlagen wurde. Jetzt haben wir "dynamisch", asyn / warten ... etc ... Warum habt ihr nicht einfach .NET-ify Javascript? ;))
Monstro

Antworten:

127

Zunächst einmal vielen Dank für Ihre freundlichen Worte. Es ist in der Tat eine großartige Funktion und ich bin froh, ein kleiner Teil davon gewesen zu sein.

Wenn mein gesamter Code langsam asynchron wird, warum nicht einfach standardmäßig alles asynchron machen?

Nun, Sie übertreiben; alle ist der Code nicht async drehen. Wenn Sie zwei "einfache" Ganzzahlen addieren, warten Sie nicht auf das Ergebnis. Wenn Sie zwei zukünftige Ganzzahlen addieren , um eine dritte zukünftige Ganzzahl zu erhalten - denn das Task<int>ist es, es ist eine Ganzzahl, auf die Sie in Zukunft zugreifen werden -, werden Sie wahrscheinlich auf das Ergebnis warten.

Der Hauptgrund, nicht alles asynchron zu machen, liegt darin, dass der Zweck von async / await darin besteht, das Schreiben von Code in einer Welt mit vielen Operationen mit hoher Latenz zu vereinfachen . Die überwiegende Mehrheit Ihrer Vorgänge weist keine hohe Latenz auf. Daher ist es nicht sinnvoll, den Leistungseinbruch zu berücksichtigen, der diese Latenz verringert. Vielmehr ist ein Schlüssel wenige sind Ihre Operationen mit hohen Latenz, und diese Operationen im gesamten Code den Zombie - Befall von Asynchron verursachen.

Wenn die Leistung das einzige Problem ist, können sicherlich einige clevere Optimierungen den Overhead automatisch entfernen, wenn er nicht benötigt wird.

In Theorie, Theorie und Praxis sind sie ähnlich. In der Praxis sind sie es nie.

Lassen Sie mich drei Punkte gegen diese Art der Transformation geben, gefolgt von einem Optimierungsdurchlauf.

Der erste Punkt ist erneut: Async in C # / VB / F # ist im Wesentlichen eine begrenzte Form der Weitergabe . In der Community der funktionalen Sprachen wurde eine enorme Menge an Forschung betrieben, um herauszufinden, wie Code optimiert werden kann, bei dem der Continuation-Passing-Stil stark genutzt wird. Das Compilerteam müsste wahrscheinlich sehr ähnliche Probleme in einer Welt lösen, in der "asynchron" die Standardeinstellung war und die nicht asynchronen Methoden identifiziert und de-asynchronisiert werden mussten. Das C # -Team ist nicht wirklich daran interessiert, offene Forschungsprobleme anzunehmen, also sind das große Punkte gegen genau dort.

Ein zweiter Punkt dagegen ist, dass C # nicht über die "referenzielle Transparenz" verfügt, die diese Art von Optimierungen leichter nachvollziehbar macht. Mit "referentieller Transparenz" meine ich die Eigenschaft, dass der Wert eines Ausdrucks nicht davon abhängt, wann er ausgewertet wird . Ausdrücke wie 2 + 2sind referenziell transparent; Sie können die Auswertung zur Kompilierungszeit durchführen, wenn Sie möchten, oder sie bis zur Laufzeit verschieben und die gleiche Antwort erhalten. Ein Ausdruck wie x+ykann jedoch nicht rechtzeitig verschoben werden, da sich x und y im Laufe der Zeit ändern können .

Async macht es viel schwieriger zu überlegen, wann eine Nebenwirkung auftreten wird. Vor dem Async, wenn Sie sagten:

M();
N();

und M()war void M() { Q(); R(); }, N()war void N() { S(); T(); }und Rund Serzeugt Nebenwirkungen, dann wissen Sie, dass die Nebenwirkung von R vor der Nebenwirkung von S auftritt. Aber wenn Sie async void M() { await Q(); R(); }dann plötzlich haben, geht das aus dem Fenster. Sie haben keine Garantie dafür, ob R()dies vorher oder nachher geschehen wird S()(es sei denn, es wird natürlich M()erwartet; aber natürlich Taskmuss es erst danach erwartet werden N().)

Stellen Sie sich nun vor, dass diese Eigenschaft, nicht mehr zu wissen, in welcher Reihenfolge Nebenwirkungen auftreten, für jeden Code in Ihrem Programm gilt, mit Ausnahme derjenigen, die der Optimierer de-asynchronisieren kann. Grundsätzlich haben Sie keine Ahnung mehr, welche Ausdrücke in welcher Reihenfolge ausgewertet werden. Dies bedeutet, dass alle Ausdrücke referenziell transparent sein müssen, was in einer Sprache wie C # schwierig ist.

Ein dritter Punkt dagegen ist, dass Sie sich dann fragen müssen: "Warum ist Async so besonders?" Wenn Sie argumentieren wollen, dass jede Operation tatsächlich eine sein sollte, müssen Task<T>Sie in der Lage sein, die Frage "Warum nicht Lazy<T>?" Zu beantworten. oder "warum nichtNullable<T> ?" oder "warum nicht IEnumerable<T>?" Weil wir das genauso gut machen könnten. Warum sollte es nicht so sein, dass jede Operation auf nullable angehoben wird ? Oder jede Operation wird träge berechnet und das Ergebnis für später zwischengespeichert , oder das Ergebnis jeder Operation ist eine Folge von Werten anstelle nur eines einzelnen Werts . Sie müssen dann versuchen, Situationen zu optimieren, in denen Sie wissen: "Oh, das darf niemals null sein, damit ich besseren Code generieren kann" und so weiter.

Der springende Punkt ist: Mir Task<T>ist nicht klar, dass das wirklich so besonders ist, um so viel Arbeit zu rechtfertigen.

Wenn Sie an solchen Dingen interessiert sind, empfehle ich Ihnen, funktionale Sprachen wie Haskell zu untersuchen, die eine viel stärkere referenzielle Transparenz aufweisen und alle Arten von Auswertungen außerhalb der Reihenfolge ermöglichen und automatisches Caching durchführen. Haskell hat auch eine viel stärkere Unterstützung in seinem Typensystem für die Art von "monadischen Aufzügen", auf die ich angespielt habe.

Eric Lippert
quelle
Asynchrone Funktionen ohne Wartezeit aufrufen zu lassen, macht für mich (im allgemeinen Fall) keinen Sinn. Wenn wir diese Funktion entfernen würden, könnte der Compiler selbst entscheiden, ob eine Funktion asynchron ist oder nicht (ruft sie wait auf?). Dann könnten wir für beide Fälle eine identische Syntax haben (asynchron und synchron) und nur das Warten in Aufrufen als Unterscheidungsmerkmal verwenden. Zombie-Befall behoben :)
Talkol
Ich habe die Diskussion in Programmierern gemäß Ihrer Anfrage fortgesetzt: programmers.stackexchange.com/questions/209872/…
talkol
@EricLippert - Sehr schöne Antwort wie immer :) Ich war neugierig, ob Sie "hohe Latenz" klären könnten? Gibt es hier einen allgemeinen Bereich in Millisekunden? Ich versuche nur herauszufinden, wo sich die untere Grenzlinie für die Verwendung von Async befindet, weil ich sie nicht missbrauchen möchte.
Travis J.
7
@TravisJ: Die Anleitung lautet: Blockieren Sie den UI-Thread nicht länger als 30 ms. Wenn Sie mehr als das tun, besteht die Gefahr, dass die Pause vom Benutzer wahrgenommen wird.
Eric Lippert
1
Was mich herausfordert, ist, dass sich ein Implementierungsdetail ändern kann, ob etwas synchron oder asynchron ausgeführt wird oder nicht. Die Änderung in dieser Implementierung erzwingt jedoch einen Welleneffekt durch den Code, der sie aufruft, den Code, der sie aufruft, und so weiter. Am Ende ändern wir den Code aufgrund des Codes, von dem er abhängt. Dies ist etwas, das wir normalerweise sehr vermeiden. Oder wir verwenden , async/awaitweil etwas unterhalb Abstraktionsschichten versteckt könnte asynchron sein.
Scott Hannen
23

Warum sollten nicht alle Funktionen asynchron sein?

Leistung ist ein Grund, wie Sie erwähnt haben. Beachten Sie, dass die Option "Schneller Pfad", mit der Sie verknüpft sind, die Leistung bei einer abgeschlossenen Aufgabe verbessert, jedoch im Vergleich zu einem einzelnen Methodenaufruf immer noch viel mehr Anweisungen und Overhead erfordert. Selbst mit dem "schnellen Pfad" erhöhen Sie mit jedem asynchronen Methodenaufruf die Komplexität und den Overhead.

Die Abwärtskompatibilität sowie die Kompatibilität mit anderen Sprachen (einschließlich Interop-Szenarien) würden ebenfalls problematisch.

Der andere ist eine Frage der Komplexität und Absicht. Asynchrone Operationen erhöhen die Komplexität - in vielen Fällen verbergen die Sprachfunktionen dies, aber es gibt viele Fälle, in denen Methoden erstellt werdenasync ihre Verwendung definitiv komplexer macht. Dies gilt insbesondere dann, wenn Sie keinen Synchronisationskontext haben, da die asynchronen Methoden dann leicht zu unerwarteten Threading-Problemen führen können.

Darüber hinaus gibt es viele Routinen, die von Natur aus nicht asynchron sind. Diese sind als synchrone Operationen sinnvoller. Erzwingen Math.Sqrtzu sein Task<double> Math.SqrtAsyncwäre lächerlich, zum Beispiel, da es kein Grund überhaupt für die asynchron zu sein. Anstatt asyncIhre Bewerbung awaitdurchzuziehen , würden Sie am Ende überall propagieren .

Dies würde auch das aktuelle Paradigma vollständig brechen und Probleme mit Eigenschaften verursachen (die praktisch nur Methodenpaare sind. Würden sie auch asynchron werden?) Und andere Auswirkungen auf das Design des Frameworks und der Sprache haben.

Wenn Sie viel IO-gebundene Arbeit erledigen, werden Sie feststellen, dass die asyncallgegenwärtige Verwendung eine großartige Ergänzung ist, viele Ihrer Routinen werden es sein async. Wenn Sie jedoch anfangen, CPU-gebundene Arbeit zu leisten, ist es im Allgemeinen asyncnicht gut , Dinge zu machen - es verbirgt die Tatsache, dass Sie CPU-Zyklen unter einer API verwenden, die asynchron zu sein scheint , aber nicht unbedingt wirklich asynchron ist.

Reed Copsey
quelle
Genau das, was ich schreiben wollte (Leistung), Abwärtskompatibilität könnte eine andere Sache sein, DLLs, die mit älteren Sprachen verwendet werden sollen, die
Async
Es ist nicht lächerlich, sqrt asynchron zu machen, wenn wir einfach die Unterscheidung zwischen Synchronisierungs- und
Asynchronisierungsfunktionen aufheben
@talkol Ich denke, ich würde das ändern - warum sollte jeder Funktionsaufruf die Komplexität der Asynchronität annehmen?
Reed Copsey
2
@talkol Ich würde argumentieren, dass dies nicht unbedingt wahr ist - Asynchronität kann selbst Fehler hinzufügen, die schlimmer sind als das Blockieren ...
Reed Copsey
1
@talkol Wie ist await FooAsync()einfacher als Foo()? Und anstatt eines kleinen Dominoeffekts haben Sie manchmal einen großen Dominoeffekt und nennen das eine Verbesserung?
svick
4

Abgesehen von der Leistung kann Asynchronität Produktivitätskosten verursachen. Auf dem Client (WinForms, WPF, Windows Phone) ist dies ein Segen für die Produktivität. Aber auf dem Server oder in anderen Szenarien ohne Benutzeroberfläche zahlen Sie Produktivität. Sie möchten dort sicherlich nicht standardmäßig asynchron arbeiten. Verwenden Sie es, wenn Sie die Vorteile der Skalierbarkeit benötigen.

Verwenden Sie es, wenn Sie am Sweet Spot sind. In anderen Fällen nicht.

usr
quelle
2
+1 - Ein einfacher Versuch, eine mentale Karte des Codes zu erstellen, in der 5 asynchrone Operationen parallel zur zufälligen Reihenfolge der Fertigstellung ausgeführt werden, wird für die meisten Menschen genug Schmerz für einen Tag verursachen. Das Denken über das Verhalten von asynchronem (und damit von Natur aus parallelem) Code ist viel schwieriger als guter alter synchroner Code ...
Alexei Levenkov
2

Ich glaube, es gibt einen guten Grund, alle Methoden asynchron zu machen, wenn sie nicht benötigt werden - Erweiterbarkeit. Selektives Erstellen von Methoden asynchron funktioniert nur, wenn sich Ihr Code nie weiterentwickelt und Sie wissen, dass Methode A () immer CPU-gebunden ist (Sie halten sie synchron) und Methode B () immer E / A-gebunden ist (Sie markieren sie als asynchron).

Aber was ist, wenn sich die Dinge ändern? Ja, A () führt Berechnungen durch, aber irgendwann in der Zukunft mussten Sie dort Protokollierung oder Berichterstellung oder benutzerdefinierten Rückruf mit nicht vorhersagbarer Implementierung hinzufügen, oder der Algorithmus wurde erweitert und umfasst jetzt nicht nur CPU-Berechnungen, sondern auch einige I / O? Sie müssen die Methode in asynchron konvertieren, dies würde jedoch die API beschädigen und alle Aufrufer im Stack müssten ebenfalls aktualisiert werden (und es können sogar verschiedene Apps von verschiedenen Anbietern sein). Oder Sie müssen neben der Synchronisierungsversion eine asynchrone Version hinzufügen, dies macht jedoch keinen großen Unterschied - die Verwendung der Synchronisierungsversion würde blockieren und ist daher kaum akzeptabel.

Es wäre großartig, wenn es möglich wäre, die vorhandene Synchronisierungsmethode asynchron zu machen, ohne die API zu ändern. In der Realität haben wir jedoch keine solche Option, und die Verwendung der asynchronen Version, auch wenn sie derzeit nicht benötigt wird, ist die einzige Möglichkeit, um sicherzustellen, dass Sie in Zukunft niemals auf Kompatibilitätsprobleme stoßen.

Alex
quelle
Obwohl dies wie ein extremer Kommentar erscheint, steckt viel Wahrheit darin. Erstens aufgrund dieses Problems: "Dies würde die API beschädigen und alle Aufrufer auf dem Stapel" async / await macht eine Anwendung enger gekoppelt. Es könnte leicht gegen das Liskov-Substitutionsprinzip verstoßen, wenn eine Unterklasse beispielsweise async / await verwenden möchte. Es ist auch schwierig, sich eine Microservices-Architektur vorzustellen, bei der die meisten Methoden nicht asynchron / warten mussten.
ItsAllABadJoke