Widersprechen die Richtlinien für die asynchrone / erwartete Verwendung in C # nicht den Konzepten einer guten Architektur und einer Abstraktionsschichtung?

103

Diese Frage betrifft die C # -Sprache, aber ich erwarte, dass sie andere Sprachen wie Java oder TypeScript abdeckt.

Microsoft empfiehlt Best Practices für die Verwendung asynchroner Aufrufe in .NET. Wählen wir unter diesen Empfehlungen zwei aus:

  • Ändern Sie die Signatur der asynchronen Methoden so, dass sie Task oder Task <> zurückgeben (in TypeScript wäre das ein Versprechen <>).
  • ändere die Namen der asynchronen Methoden so, dass sie mit xxxAsync () enden

Wenn Sie nun eine synchrone Komponente auf niedriger Ebene durch eine asynchrone Komponente ersetzen, wirkt sich dies auf den gesamten Stapel der Anwendung aus. Da sich async / await nur dann positiv auswirkt, wenn es "ganz nach oben" verwendet wird, müssen die Signatur und die Methodennamen aller Ebenen in der Anwendung geändert werden.

Eine gute Architektur beinhaltet häufig das Platzieren von Abstraktionen zwischen den einzelnen Ebenen, sodass das Ersetzen von Komponenten auf niedriger Ebene durch andere von den Komponenten auf höherer Ebene nicht gesehen wird. In C # haben Abstraktionen die Form von Schnittstellen. Wenn wir eine neue asynchrone Komponente auf niedriger Ebene einführen, muss jede Schnittstelle im Aufrufstapel entweder geändert oder durch eine neue Schnittstelle ersetzt werden. Die Art und Weise, wie ein Problem in einer implementierenden Klasse gelöst wird (asynchron oder synchron), ist für die Aufrufer nicht mehr verborgen (abstrahiert). Die Anrufer müssen wissen, ob es synchron oder asynchron ist.

Sind nicht asynchrone / erwartete Best Practices, die im Widerspruch zu den Prinzipien der "guten Architektur" stehen?

Bedeutet dies, dass jede Schnittstelle (z. B. IEnumerable, IDataAccessLayer) ihr asynchrones Gegenstück (IAsyncEnumerable, IAsyncDataAccessLayer) benötigt, damit sie beim Wechsel zu asynchronen Abhängigkeiten im Stapel ersetzt werden können?

Wenn wir das Problem ein wenig weiter schieben, wäre es nicht einfacher anzunehmen, dass jede Methode asynchron ist (um eine Aufgabe <> oder eine Zusage <> zurückzugeben), und dass die Methoden die asynchronen Aufrufe synchronisieren, wenn sie nicht tatsächlich sind asynchron? Ist das von den zukünftigen Programmiersprachen zu erwarten?

corentinaltepe
quelle
5
Das klingt nach einer großartigen Diskussionsfrage, ist aber meiner Meinung nach zu meinungsbasiert, um hier beantwortet zu werden.
Euphoric
22
@Euphoric: Ich denke, das hier skizzierte Problem geht tiefer als die C # -Richtlinien. Dies ist nur ein Symptom für die Tatsache, dass das Umstellen von Teilen einer Anwendung auf asynchrones Verhalten nicht lokale Auswirkungen auf das Gesamtsystem haben kann. Mein Bauch sagt mir also, dass es auf der Grundlage technischer Fakten eine unbefangene Antwort geben muss. Daher ermutige ich alle hier, diese Frage nicht zu früh zu schließen, sondern abzuwarten, welche Antworten kommen werden (und wenn sie zu eigensinnig sind, können wir immer noch für den Abschluss stimmen).
Doc Brown
25
@DocBrown Ich denke, die tiefere Frage hier ist: "Kann ein Teil des Systems von synchron auf asynchron geändert werden, ohne dass Teile, die davon abhängen, sich ebenfalls ändern müssen?" Ich denke, die Antwort darauf ist klar "nein". In diesem Fall sehe ich nicht, wie "Konzepte guter Architektur und Schichtung" hier zutreffen.
Euphoric
6
@ Euphoric: Klingt nach einer guten Grundlage für eine unbefangene Antwort ;-)
Doc Brown
5
@Gherman: weil C #, wie viele andere Sprachen, keine Überladung allein aufgrund des Rückgabetyps durchführen kann. Am Ende stehen Ihnen asynchrone Methoden zur Verfügung, die die gleiche Signatur wie die entsprechenden Synchronisationsmethoden haben (nicht alle können ein annehmen CancellationToken, und diejenigen, die dies tun, möchten möglicherweise eine Standardmethode angeben). Das Entfernen der vorhandenen Synchronisierungsmethoden (und das proaktive Brechen des gesamten Codes) ist ein offensichtlicher Nichtstarter.
Jeroen Mostert

Antworten:

111

Welche Farbe hat deine Funktion?

Sie könnten an Bob Nystroms What Color Is Your Function 1 interessiert sein .

In diesem Artikel beschreibt er eine fiktive Sprache, in der:

  • Jede Funktion hat eine Farbe: blau oder rot.
  • Eine rote Funktion kann entweder blaue oder rote Funktionen aufrufen, kein Problem.
  • Eine blaue Funktion darf nur blaue Funktionen aufrufen.

Dies ist zwar fiktiv, geschieht jedoch in folgenden Programmiersprachen regelmäßig:

  • In C ++ kann eine "const" -Methode nur andere "const" -Methoden aufrufen this.
  • In Haskell kann eine Nicht-E / A-Funktion nur Nicht-E / A-Funktionen aufrufen.
  • In C # kann eine Synchronisierungsfunktion nur Synchronisierungsfunktionen 2 aufrufen .

Wie Sie erkannt haben, neigen rote Funktionen aufgrund dieser Regeln dazu, sich in der Codebasis zu verbreiten . Sie fügen eine ein und nach und nach kolonisiert sie die gesamte Codebasis.

1 Bob Nystrom ist neben dem Bloggen auch Teil des Dart-Teams und hat diese kleine Crafting Interpreters-Serie geschrieben. Sehr empfehlenswert für alle Programmiersprachen / Compiler-Fans.

2 Nicht ganz richtig, da Sie möglicherweise eine asynchrone Funktion aufrufen und blockieren, bis sie zurückkehrt, aber ...

Spracheinschränkung

Dies ist im Wesentlichen eine Sprach- / Laufzeitbeschränkung.

Eine Sprache mit M: N-Threading, z. B. Erlang und Go, hat keine asyncFunktionen: Jede Funktion ist möglicherweise asynchron, und ihre "Faser" wird einfach angehalten, ausgetauscht und wieder eingelesen, wenn sie wieder bereit ist.

C # entschied sich für ein 1: 1-Threading-Modell und daher für die Oberflächensynchronität in der Sprache, um ein versehentliches Blockieren von Threads zu vermeiden.

Bei Spracheinschränkungen müssen die Kodierungsrichtlinien angepasst werden.

Matthieu M.
quelle
4
E / A-Funktionen neigen dazu, sich zu verbreiten, aber mit Sorgfalt können Sie sie größtenteils auf Funktionen in der Nähe der Einstiegspunkte Ihres Codes (beim Aufrufen im Stapel) isolieren. Sie können dies tun, indem diese Funktionen die E / A-Funktionen aufrufen und dann andere Funktionen die Ausgabe von ihnen verarbeiten und alle Ergebnisse zurückgeben, die für weitere E / A benötigt werden. Ich finde, dass dieser Stil meine Codebasen viel einfacher zu verwalten und zu bearbeiten macht. Ich frage mich, ob es eine Folge von Synchronizität gibt.
jpmc26
16
Was meinst du mit "M: N" und "1: 1" Threading?
Captain Man
14
@CaptainMan: 1: 1-Threading bedeutet, dass ein Anwendungsthread einem Betriebssystemthread zugeordnet wird. Dies ist in Sprachen wie C, C ++, Java oder C # der Fall. Im Gegensatz dazu bedeutet M: N-Threading, dass M Anwendungsthreads N OS-Threads zugeordnet werden. Im Fall von Go wird ein Anwendungsthread als "Goroutine" bezeichnet, im Fall von Erlang als "Schauspieler", und Sie haben möglicherweise auch von ihnen als "grüne Fäden" oder "Fasern" gehört. Sie bieten Parallelität, ohne Parallelität zu erfordern. Leider ist der Wikipedia-Artikel zum Thema eher spärlich.
Matthieu M.
2
Dies ist ein wenig verwandt, aber ich denke, diese Idee der "Funktionsfarbe" gilt auch für Funktionen, die die Eingabe durch den Benutzer blockieren, z. B. modale Dialogfelder, Meldungsfelder, einige Formen von Konsolen-E / A usw. Rahmen hatte von Anfang an.
jrh
2
@MatthieuM. C # hat keinen Anwendungsthread pro Betriebssystemthread und hat dies auch nie getan. Dies ist sehr offensichtlich, wenn Sie mit nativem Code interagieren, und besonders offensichtlich, wenn Sie mit MS SQL arbeiten. Und natürlich waren kooperative Routinen immer möglich (und sind noch einfacher damit async); In der Tat war es ein ziemlich verbreitetes Muster für die Erstellung einer reaktionsfähigen Benutzeroberfläche. War es so hübsch wie Erlang? Nee. Aber das ist noch weit entfernt von C :)
Luaan
82

Sie haben Recht, es gibt hier einen Widerspruch, aber es ist nicht die "beste Vorgehensweise", schlecht zu sein. Dies liegt daran, dass eine asynchrone Funktion wesentlich andere Funktionen ausführt als eine synchrone. Anstatt auf das Ergebnis seiner Abhängigkeiten zu warten (normalerweise einige E / A-Vorgänge), wird eine Aufgabe erstellt, die von der Hauptereignisschleife behandelt wird. Dies ist kein Unterschied, der sich unter Abstraktion gut verbergen lässt.

max630
quelle
27
Die Antwort ist so einfach wie diese IMO. Der Unterschied zwischen einem synchronen und einem asynchronen Prozess ist kein Implementierungsdetail, sondern ein semantisch anderer Vertrag.
Ant P
11
@AntP: Ich stimme nicht zu, dass es so einfach ist. es taucht in der Sprache C # auf, aber beispielsweise nicht in der Sprache Go. Dies ist also keine inhärente Eigenschaft von asynchronen Prozessen. Es geht darum, wie asynchrone Prozesse in der angegebenen Sprache modelliert werden.
Matthieu M.
1
@MatthieuM. Ja, aber Sie können asyncMethoden in C # verwenden, um auch synchrone Verträge bereitzustellen, wenn Sie möchten. Der einzige Unterschied besteht darin, dass Go standardmäßig asynchron ist, während C # standardmäßig synchron ist. asyncgibt Ihnen das zweite Programmiermodell - async ist die Abstraktion (was es tatsächlich tut, hängt von der Laufzeit, dem Task-Scheduler, dem Synchronisationskontext, der Waiter-Implementierung ... ab).
Luaan
6

Wie Sie sicher wissen, verhält sich eine asynchrone Methode anders als eine synchrone. Zur Laufzeit ist die Umwandlung eines asynchronen in einen synchronen Aufruf trivial, aber das Gegenteil kann nicht gesagt werden. Also wird die Logik dann, warum machen wir nicht asynchrone Methoden von jeder Methode, die es erfordern könnte, und lassen den Aufrufer nach Bedarf in eine synchrone Methode "konvertieren"?

In gewissem Sinne ist es so, als hätte man eine Methode, die Ausnahmen auslöst, und eine andere, die "sicher" ist und selbst im Fehlerfall keine Ausnahmen macht. Ab wann ist der Codierer übermäßig, um diese Methoden bereitzustellen, die andernfalls in andere konvertiert werden können?

Dabei gibt es zwei Denkrichtungen: Eine besteht darin, mehrere Methoden zu erstellen, von denen jede eine andere, möglicherweise private Methode, aufruft und die Möglichkeit bietet, optionale Parameter oder geringfügige Änderungen des Verhaltens wie Asynchronität bereitzustellen. Die andere besteht darin, die Schnittstellenmethoden auf das Nötigste zu beschränken und es dem Aufrufer zu überlassen, die erforderlichen Änderungen selbst vorzunehmen.

Wenn Sie zur ersten Schule gehören, gibt es eine gewisse Logik, eine Klasse für synchrone und asynchrone Anrufe zu reservieren, um zu vermeiden, dass sich jeder Anruf verdoppelt. Microsoft tendiert dazu, diese Denkweise zu bevorzugen, und um dem von Microsoft favorisierten Stil zu entsprechen, müssten auch Sie eine Async-Version haben, ähnlich wie Schnittstellen fast immer mit einem "I" beginnen. Lassen Sie mich betonen, dass es per se nicht falsch ist, weil es besser ist, einen konsistenten Stil in einem Projekt beizubehalten, als ihn "richtig" zu machen und den Stil für die Entwicklung, die Sie einem Projekt hinzufügen, radikal zu ändern.

Trotzdem bevorzuge ich die zweite Schule, um die Schnittstellenmethoden zu minimieren. Wenn ich denke, dass eine Methode asynchron aufgerufen werden kann, ist die Methode für mich asynchron. Der Anrufer kann entscheiden, ob er auf die Beendigung dieser Aufgabe warten möchte oder nicht, bevor er fortfährt. Wenn es sich bei dieser Schnittstelle um eine Schnittstelle zu einer Bibliothek handelt, ist dies sinnvoller, um die Anzahl der Methoden zu minimieren, die Sie veralten oder anpassen müssen. Wenn die Schnittstelle für die interne Verwendung in meinem Projekt vorgesehen ist, füge ich für jeden erforderlichen Aufruf im gesamten Projekt eine Methode für die angegebenen Parameter und keine "zusätzlichen" Methoden hinzu, und zwar nur dann, wenn das Verhalten der Methode noch nicht abgedeckt ist durch eine bestehende Methode.

Wie viele andere Dinge in diesem Bereich ist es jedoch weitgehend subjektiv. Beide Ansätze haben Vor- und Nachteile. Microsoft hat außerdem die Konvention eingeführt, am Anfang des Variablennamens Buchstaben einzufügen, die den Typ anzeigen, und "m_", um anzuzeigen, dass es sich um ein Mitglied handelt, was zu Variablennamen wie führt m_pUser. Mein Punkt ist, dass nicht einmal Microsoft unfehlbar ist und auch Fehler machen kann.

Das heißt, wenn Ihr Projekt dieser Async-Konvention folgt, rate ich Ihnen, diese zu respektieren und den Stil fortzusetzen. Und erst wenn Sie ein eigenes Projekt erhalten haben, können Sie es so schreiben, wie Sie es für richtig halten.

Neil
quelle
6
"Zur Laufzeit ist es trivial, einen asynchronen in einen synchronen Aufruf umzuwandeln" Ich bin mir nicht sicher, ob es genau so ist. In .NET kann die Verwendung von .Wait()method and like negative Konsequenzen haben, und soweit ich weiß, ist dies in js überhaupt nicht möglich.
Max. 6.30
2
@ max630 Ich habe nicht gesagt, dass keine gleichzeitigen Probleme zu berücksichtigen sind, aber wenn es anfangs eine synchrone Aufgabe war, ist es wahrscheinlich, dass es keine Deadlocks erzeugt. Das heißt, trivial , bedeutet nicht , „Doppelklick hier zu konvertieren , um synchron“. In js geben Sie eine Promise-Instanz zurück und rufen resolve auf.
Neil
2
Ja, es ist total nervig, Async zurück in Sync umzuwandeln
Ewan
4
@Neil In JavaScript werden Promise.resolve(x)diese Rückrufe nicht sofort ausgeführt , selbst wenn Sie sie aufrufen und anschließend hinzufügen.
NickL
1
@Neil Wenn eine Schnittstelle eine asynchrone Methode bereitstellt, ist es keine gute Annahme, zu erwarten, dass das Warten auf die Task keinen Deadlock erzeugt. Es ist viel besser, wenn die Benutzeroberfläche anzeigt, dass sie in der Methodensignatur tatsächlich synchron ist, als ein Versprechen in der Dokumentation, das sich in einer späteren Version ändern könnte.
Carl Walsh
2

Stellen wir uns vor, es gibt eine Möglichkeit, Funktionen asynchron aufzurufen, ohne ihre Signatur zu ändern.

Das wäre wirklich cool und niemand würde empfehlen, den Namen zu ändern.

Tatsächliche asynchrone Funktionen, nicht nur solche, die auf eine andere asynchrone Funktion warten, sondern auch die unterste Ebene, weisen eine Struktur auf, die für ihre asynchrone Natur spezifisch ist. z.B

public class HTTPClient
{
    public HTTPResponse GET()
    {
        //send data
        while(!timedOut)
        {
            //check for response
            if(response) { 
                this.GotResponse(response); 
            }
            this.YouCanWait();
        }
    }

    //tell calling code that they should watch for this event
    public EventHander GotResponse
    //indicate to calling code that they can go and do something else for a bit
    public EventHander YouCanWait;
}

Es sind diese beiden Informationen, die der aufrufende Code benötigt, um den Code auf eine asynchrone Art Taskund Weise auszuführen, die Dinge mögen und asynckapseln.

Es gibt mehr als eine Möglichkeit, asynchrone Funktionen auszuführen. Es async Taskgibt nur ein Muster, das über Rückgabetypen in den Compiler integriert wird, damit Sie die Ereignisse nicht manuell verknüpfen müssen

Ewan
quelle
0

Ich werde den Hauptpunkt auf eine weniger kultivierte und allgemeinere Weise ansprechen:

Sind nicht asynchrone / erwartete Best Practices, die im Widerspruch zu den Prinzipien der "guten Architektur" stehen?

Ich würde sagen, dass es nur von der Wahl abhängt, die Sie im Design Ihrer API treffen und was Sie dem Benutzer überlassen.

Wenn eine Funktion Ihrer API nur asynchron sein soll, besteht wenig Interesse daran, die Namenskonvention einzuhalten. Geben Sie einfach immer Task <> / Promise <> / Future <> / ... als Rückgabetyp zurück, es ist selbstdokumentierend. Wenn er eine Synchronisierungsantwort haben möchte, kann er das immer noch durch Warten tun, aber wenn er das immer tut, macht es ein bisschen Boilerplate.

Wenn Sie jedoch nur Ihre API-Synchronisierung durchführen, bedeutet dies, dass ein Benutzer den asynchronen Teil selbst verwalten muss, wenn er möchte, dass er asynchron ist.

Dies kann eine Menge zusätzlicher Arbeit bedeuten, aber es kann dem Benutzer auch mehr Kontrolle darüber geben, wie viele gleichzeitige Anrufe er zulässt, eine Zeitüberschreitung einleitet, erneut versucht und so weiter.

In einem großen System mit einer großen API ist es möglicherweise einfacher und effizienter, die meisten von ihnen standardmäßig für die Synchronisierung zu implementieren, als jeden Teil Ihrer API unabhängig zu verwalten, insbesondere wenn sie Ressourcen (Dateisystem, CPU, Datenbank, ...) gemeinsam nutzen.

Tatsächlich könnten Sie für die komplexesten Teile perfekt zwei Implementierungen desselben Teils Ihrer API haben, eine synchrone, die die praktischen Dinge erledigt, eine asynchrone, die sich auf die synchronen Dinge verlässt und nur Parallelität, Ladevorgänge, Zeitüberschreitungen und Wiederholungsversuche verwaltet .

Vielleicht kann jemand anderes seine Erfahrungen damit teilen, weil mir Erfahrungen mit solchen Systemen fehlen.

Walfrat
quelle
2
@Miral Sie haben in beiden Möglichkeiten "eine asynchrone Methode von einer Synchronisationsmethode aufrufen" verwendet.
Adrian Wragg
@AdrianWragg Also habe ich getan; Mein Gehirn muss einen Rennzustand gehabt haben. Ich werde es reparieren.
Miral
Es ist anders herum; Es ist trivial, eine Async-Methode von einer Sync-Methode aufzurufen, aber es ist unmöglich, eine Sync-Methode von einer Async-Methode aufzurufen. (Und wo die Dinge völlig auseinanderfallen, versucht sowieso jemand, Letzteres zu tun, was zu Deadlocks führen kann.) Wenn Sie also eines auswählen müssen, ist standardmäßig Async die bessere Wahl. Leider ist es auch die schwierigere Wahl, da eine asynchrone Implementierung nur asynchrone Methoden aufrufen kann.
Miral
(Und damit meine ich natürlich eine blockierende Synchronisationsmethode. Sie können etwas aufrufen, das eine reine CPU-gebundene Berechnung synchron aus einer asynchronen Methode erstellt - obwohl Sie versuchen sollten, dies zu vermeiden, es sei denn, Sie wissen, dass Sie sich in einem Worker-Kontext befinden eher als ein UI-Kontext - aber das Blockieren von Aufrufen, die auf einem Schloss oder für E / A oder für eine andere Operation im Leerlauf warten, ist eine schlechte Idee.)
Miral