Freigeben über


Asynchrone Datenströme

Hinweis

Dieser Artikel ist eine Feature-Spezifikation. Die Spezifikation dient als Designdokument für das Feature. Es enthält vorgeschlagene Spezifikationsänderungen sowie Informationen, die während des Entwurfs und der Entwicklung des Features erforderlich sind. Diese Artikel werden veröffentlicht, bis die vorgeschlagenen Spezifikationsänderungen abgeschlossen und in die aktuelle ECMA-Spezifikation aufgenommen werden.

Es kann einige Abweichungen zwischen der Feature-Spezifikation und der abgeschlossenen Implementierung geben. Diese Unterschiede werden in den entsprechenden Hinweisen zum Language Design Meeting (LDM) erfasst.

Weitere Informationen zum Prozess für die Aufnahme von Funktions-Speclets in den C#-Sprachstandard finden Sie im Artikel zu den Spezifikationen.

Champion-Problem: https://github.com/dotnet/csharplang/issues/43

Zusammenfassung

C# unterstützt Iteratormethoden und asynchrone Methoden. Die Programmiersprache unterstützt jedoch keine Methoden, bei denen es sich sowohl um eine Iteratormethode als auch um eine asynchrone Methode handelt. Wir sollten dies korrigieren und die Verwendung von await in einer neuen Form von async-Iterator zulassen, der IAsyncEnumerable<T> oder IAsyncEnumerator<T> anstelle von IEnumerable<T> oder IEnumerator<T> zurückgibt, wobei IAsyncEnumerable<T> in einem neuen await foreach verwendet werden kann. Es wird auch eine IAsyncDisposable-Schnittstelle verwendet, um eine asynchrone Bereinigung zu ermöglichen.

Detailliertes Design

Schnittstellen

IAsyncDisposable

Es wurde viel über IAsyncDisposable (z. B. https://github.com/dotnet/roslyn/issues/114) diskutiert und ob dies eine gute Idee sei. Es handelt sich jedoch um ein erforderliches Konzept, das zur Unterstützung asynchroner Iteratoren hinzugefügt werden muss. Da finally-Blöcke awaits enthalten können und finally-Blöcke im Rahmen der Beseitigung von Iteratoren ausgeführt werden müssen, benötigen wir eine asynchrone Beseitigung. Es ist auch ganz allgemein nützlich, wenn die Bereinigung von Ressourcen eine gewisse Zeit in Anspruch nehmen könnte, z. B. beim Schließen von Dateien (Leerungen erforderlich), beim Deregistrieren von Rückrufen, bei der Bereitstellung einer Möglichkeit, festzustellen, wann die Deregistrierung abgeschlossen ist usw.

Die folgende Schnittstelle wird den .NET-Kernbibliotheken (z. B. System.Private.CoreLib/System.Runtime) hinzugefügt:

namespace System
{
    public interface IAsyncDisposable
    {
        ValueTask DisposeAsync();
    }
}

Wie bei Dispose ist das mehrfache Aufrufen von DisposeAsync akzeptabel, und nachfolgende Aufrufe nach dem ersten sollten als Nulloperationen behandelt werden und eine synchron abgeschlossene erfolgreiche Aufgabe zurückgeben (DisposeAsync muss jedoch nicht threadsicher sein und muss keine gleichzeitigen Aufrufe unterstützen). Darüber hinaus können Typen sowohl IDisposable als auch IAsyncDisposable implementieren, und wenn sie dies tun, ist es ebenso akzeptabel, Dispose und dann DisposeAsync aufzurufen, oder umgekehrt. Allerdings sollte nur der erste Aufruf bedeutsam sein, bei nachfolgenden Aufrufen von beiden sollte es sich um eine NOP handeln. Wenn ein Typ beide implementiert, werden Verbraucher ermutigt, die je nach Kontext relevantere Methode ein einziges Mal aufzurufen, Dispose in synchronen Kontexten und DisposeAsync in asynchronen Kontexten.

(Wie IAsyncDisposable mit using interagiert, ist eine separate Diskussion. Und die Beschreibung der Interaktion mit foreach wird später in diesem Vorschlag behandelt.)

Berücksichtigte Alternativen:

  • DisposeAsync akzeptiert CancellationToken: Theoretisch ist es zwar sinnvoll, dass alle asynchronen Vorgänge abgebrochen werden können, bei der Beseitigung geht es jedoch um eine Bereinigung, das Schließen von Prozessen und die Freigabe von Ressourcen usw. Dies sollte in der Regel nicht abgebrochen werden; eine Bereinigung ist auch bei abgebrochenen Arbeiten wichtig. Das CancellationToken, das die Unterbrechung der Arbeit verursacht hat, ist in der Regel auch das Token, das an DisposeAsync übergeben wird. Damit wird DisposeAsync wertlos, da der Abbruch der Arbeit dazu führen würde, dass DisposeAsync zu einer Nulloperation wird. Wer es vermeiden möchte, durch das Warten auf die Beseitigung blockiert zu werden, kann das Warten auf die resultierende ValueTask vermeiden oder nur für einen gewissen Zeitraum darauf warten.
  • DisposeAsync gibt Task zurück: Da jetzt eine nicht generische ValueTask vorhanden ist und aus einer IValueTaskSource erstellt werden kann, ermöglicht die Rückgabe einer ValueTask von DisposeAsync die Wiederverwendung eines bestehenden Objekts als Versprechen, das die letztendliche asynchrone Fertigstellung von DisposeAsync darstellt. Dadurch kann eine Task-Zuordnung gespart werden, falls DisposeAsync asynchron abgeschlossen wird.
  • Konfiguration von DisposeAsync mit einem bool continueOnCapturedContext (ConfigureAwait): Es kann zwar Probleme in Bezug darauf geben, wie ein solches Konzept using, foreach und anderen Sprachkonstrukten, die dies nutzen, verfügbar gemacht wird, aus der Sicht der Benutzeroberfläche führt es jedoch kein await durch und es gibt nichts zu konfigurieren ... Nutzer der ValueTask können es beliebig verwenden.
  • IAsyncDisposable erbt IDisposable: Da nur das eine oder das andere verwendet werden soll, ist es nicht sinnvoll, Typen zur Implementierung von beiden zu zwingen.
  • IDisposableAsync anstelle von IAsyncDisposable: Wir haben uns an die Namensgebung gehalten, wonach Dinge/Typen „etwas Asynchrones“ sind, während Vorgänge „asynchron erfolgen“. Somit haben Typen das Präfix „Async“ und Methoden das Suffix „Async“.

IAsyncEnumerable / IAsyncEnumerator

Zwei Schnittstellen werden den .NET-Kernbibliotheken hinzugefügt:

namespace System.Collections.Generic
{
    public interface IAsyncEnumerable<out T>
    {
        IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default);
    }

    public interface IAsyncEnumerator<out T> : IAsyncDisposable
    {
        ValueTask<bool> MoveNextAsync();
        T Current { get; }
    }
}

Typischer Energieverbrauch (ohne zusätzliche Sprachfunktionen) würde wie folgt aussehen:

IAsyncEnumerator<T> enumerator = enumerable.GetAsyncEnumerator();
try
{
    while (await enumerator.MoveNextAsync())
    {
        Use(enumerator.Current);
    }
}
finally { await enumerator.DisposeAsync(); }

Berücksichtigte verworfene Optionen:

  • Task<bool> MoveNextAsync(); T current { get; }: Die Verwendung von Task<bool> würde die Nutzung eines zwischengespeicherten Aufgabenobjekts zur Darstellung synchroner, erfolgreicher MoveNextAsync-Aufrufe unterstützen. Es wäre jedoch weiterhin eine Zuordnung für den asynchronen Abschluss erforderlich. Durch die Rückgabe von ValueTask<bool> wird es dem Enumeratorobjekt ermöglicht, selbst IValueTaskSource<bool> zu implementieren und als Unterstützung für das von ValueTask<bool> zurückgegebene MoveNextAsync zu dienen. Dadurch kann der Aufwand deutlich reduziert werden.
  • ValueTask<(bool, T)> MoveNextAsync();: Nicht nur schwieriger zu verwenden, sondern bedeutet auch, dass T nicht mehr kovariant sein kann.
  • ValueTask<T?> TryMoveNextAsync();: Nicht kovariant
  • Task<T?> TryMoveNextAsync();: Nicht kovariant, Zuordnungen für jeden Aufruf usw.
  • ITask<T?> TryMoveNextAsync();: Nicht kovariant, Zuordnungen für jeden Aufruf usw.
  • ITask<(bool,T)> TryMoveNextAsync();: Nicht kovariant, Zuordnungen für jeden Aufruf usw.
  • Task<bool> TryMoveNextAsync(out T result);: Das out-Ergebnis müsste festgelegt werden, wenn der Vorgang synchron zurückgegeben wird, nicht, wenn er die Aufgabe möglicherweise irgendwann weit in der Zukunft asynchron abschließt. Zu diesem Zeitpunkt wäre es nicht mehr möglich, das Ergebnis zu kommunizieren.
  • IAsyncEnumerator<T> implementiert IAsyncDisposable nicht: Wir könnten diese trennen. Dies verkompliziert jedoch bestimmte andere Bereiche des Vorschlags, da der Code dann in der Lage sein muss, mit der Möglichkeit umzugehen, dass ein Enumerator keine Beseitigung bietet. Dies macht es schwierig, musterbasierte Hilfsprogramme zu schreiben. Darüber hinaus werden Enumeratoren häufig eine Beseitigung benötigen (z. B. jeder asynchrone C#-Iterator mit einem finally-Block, die meisten Dinge, die Daten aus einer Netzwerkverbindung aufzählen usw.), und wenn nicht, ist es einfach, die Methode mit minimalem zusätzlichem Aufwand einfach nur als public ValueTask DisposeAsync() => default(ValueTask); zu implementieren.
  • _ IAsyncEnumerator<T> GetAsyncEnumerator(): Kein Abbruch-Token-Parameter.

Im folgenden Unterabschnitt werden Alternativen erläutert, die nicht ausgewählt wurden.

Praktikable Alternative:

namespace System.Collections.Generic
{
    public interface IAsyncEnumerable<out T>
    {
        IAsyncEnumerator<T> GetAsyncEnumerator();
    }

    public interface IAsyncEnumerator<out T> : IAsyncDisposable
    {
        ValueTask<bool> WaitForNextAsync();
        T TryGetNext(out bool success);
    }
}

TryGetNext wird in einer inneren Schleife verwendet, um Elemente mit einem einzelnen Schnittstellenaufruf zu nutzen, solange diese synchron verfügbar sind. Wenn das nächste Element nicht synchron abgerufen werden kann, wird „Falsch“ zurückgegeben, und jedes Mal, wenn „Falsch“ zurückgegeben wird, muss anschließend über eine aufrufende Funktion WaitForNextAsync aufgerufen werden, um entweder auf das nächste Element zu warten oder festzustellen, dass es kein anderes Element gibt. Typischer Energieverbrauch (ohne zusätzliche Sprachfunktionen) würde wie folgt aussehen:

IAsyncEnumerator<T> enumerator = enumerable.GetAsyncEnumerator();
try
{
    while (await enumerator.WaitForNextAsync())
    {
        while (true)
        {
            int item = enumerator.TryGetNext(out bool success);
            if (!success) break;
            Use(item);
        }
    }
}
finally { await enumerator.DisposeAsync(); }

Dies bringt zwei Vorteile mit sich, einen kleineren und einen größeren:

  • Kleinerer Vorteil: Ein Enumerator kann mehrere Consumer unterstützen. In bestimmten Szenarien ist es hilfreich, wenn ein Enumerator mehrere gleichzeitige Consumer unterstützt. Dies ist nicht möglich, wenn MoveNextAsync und Current getrennt sind, sodass eine Implementierung ihre Nutzung nicht atomisch machen kann. Im Gegensatz dazu bietet dieser Ansatz eine einzelne Methode TryGetNext, die das Vorwärtsschieben des Enumerators und das Abrufen des nächsten Elements unterstützt, sodass der Enumerator bei Bedarf Atomarität aktivieren kann. Es ist jedoch wahrscheinlich, dass solche Szenarien auch ermöglicht werden könnten, indem jeder Consumer seinen eigenen Enumerator aus einem gemeinsamen Enumerablen erhält. Darüber hinaus möchten wir nicht erzwingen, dass jeder Enumerator die gleichzeitige Nutzung unterstützt, da dies einen nicht unerheblichen Aufwand für die Mehrzahl der Fälle bedeuten würde, die dies nicht benötigen, und ein Nutzer der Schnittstelle somit in der Regel ohnehin nicht darauf vertrauen könnte.
  • Größerer Vorteil: Leistung. Der Ansatz MoveNextAsync/Current erfordert zwei Schnittstellenaufrufe pro Vorgang, während bei WaitForNextAsync/TryGetNext im besten Fall die meisten Iterationen synchron abgeschlossen werden. Hierdurch wird eine enge innere Schleife mit TryGetNext aktiviert, sodass es nur einen Schnittstellenaufruf pro Vorgang gibt. Dies kann eine messbare Auswirkung in Situationen haben, in denen die Schnittstellenaufrufe das Berechnungsergebnis maßgeblich bestimmen.

Es gibt jedoch nicht unerhebliche Nachteile, einschließlich einer deutlich höheren Komplexität bei manueller Nutzung und einer erhöhten Gefahr von Fehlern bei der Nutzung. Und während die Leistungsvorteile in Mikrobenchmarks deutlich werden, glauben wir nicht, dass sie in den meisten realen Anwendungsszenarien signifikant sein werden. Sollte sich herausstellen, dass dies doch der Fall ist, können wir eine zweite Gruppe von Schnittstellen einführen.

Berücksichtigte verworfene Optionen:

  • ValueTask<bool> WaitForNextAsync(); bool TryGetNext(out T result);: out-Parameter können nicht kovariant sein. Es gibt hier auch eine kleine Auswirkung (ein Problem mit dem try-Muster im Allgemeinen), dass dies wahrscheinlich eine Laufzeitschreibbarriere für Referenztypergebnisse verursacht.

Abbruch

Es gibt mehrere mögliche Ansätze zur Unterstützung des Abbruchs:

  1. IAsyncEnumerable<T> / IAsyncEnumerator<T> sind abbruchunabhängig: CancellationToken taucht nirgendwo auf. Ein Abbruch erfolgt durch logisches Einbinden des CancellationToken in das Enumerable und/oder den Enumerator auf die jeweils geeignete Weise, z. B. beim Aufruf eines Iterators, bei der Übergabe des CancellationToken als Argument an die Iteratormethode und bei der Verwendung im Textkörper des Iterators, wie bei jedem anderen Parameter.
  2. IAsyncEnumerator<T>.GetAsyncEnumerator(CancellationToken): Sie übergeben ein CancellationToken an GetAsyncEnumerator, und nachfolgende MoveNextAsync-Vorgänge berücksichtigen es so gut wie möglich.
  3. IAsyncEnumerator<T>.MoveNextAsync(CancellationToken): Sie übergeben ein CancellationToken an jeden einzelnen MoveNextAsync-Aufruf.
  4. 1 && 2: Sie betten CancellationTokens in Ihr Enumerable/Ihren Enumerator ein und übergeben CancellationTokens an GetAsyncEnumerator.
  5. 1 && 3: Sie betten CancellationTokens in Ihr Enumerable/Ihren Enumerator ein und übergeben CancellationTokens an MoveNextAsync.

Rein theoretisch ist (5) die robusteste Möglichkeit, da (a) es die präziseste Kontrolle darüber ermöglicht, was abgebrochen wird, wenn MoveNextAsync ein CancellationToken akzeptiert, und (b) CancellationToken einfach ein beliebiger anderer Typ ist, der als Argument an Iteratoren übergeben werden kann, in beliebige Typen eingebettet werden kann usw.

Bei diesem Ansatz gibt es jedoch mehrere Probleme:

  • Wie gelangt ein CancellationToken, das an GetAsyncEnumerator übergeben wird, in den Textkörper des Iterators? Wir könnten ein neues iterator-Schlüsselwort verfügbar machen, mit dem Sie auf das an CancellationToken übergebene GetEnumerator zugreifen können, aber a) erfordert das viel zusätzlichen Aufwand, b) machen wir es zu einem First-Class Citizen, und c) scheint es sich in 99 % der Fälle um denselben Code zu handeln, der sowohl einen Iterator als auch GetAsyncEnumerator darauf aufruft. In diesem Fall kann das CancellationToken einfach als Argument an die Methode übergeben werden.
  • Wie gelangt ein CancellationToken, das an MoveNextAsync übergeben wird, in den Textkörper der Methode? Dies ist noch schlechter, denn wenn es von einem lokalen iterator-Objekt verfügbar gemacht wird, könnte sich sein Wert über awaits hinweg ändern. Das bedeutet, dass sämtlicher Code, der mit dem Token registriert wurde, die Registrierung vor awaits aufheben und sich danach erneut registrieren müsste. Es ist auch potenziell ziemlich teuer, bei jedem MoveNextAsync-Aufruf eine solche Registrierung durchführen und wieder aufheben zu müssen, unabhängig davon, ob die Implementierung über den Compiler in einem Iterator erfolgt ist oder manuell von einem Entwickler vorgenommen wurde.
  • Wie bricht ein Entwickler eine foreach-Schleife ab? Wenn hierfür einem Enumerable/Enumerator ein CancellationToken zugeordnet wird, müssen wir entweder a) ein foreaching über Enumeratoren unterstützen, wodurch diese zu First-Class Citizens werden, und dann über ein Ökosystem um die Enumeratoren herum nachdenken (z. B. LINQ-Methoden), oder b) das CancellationToken ohnehin in das Enumerable einbetten, indem wir eine WithCancellation-Erweiterungsmethode von IAsyncEnumerable<T> verwenden, die das bereitgestellte Token speichert und dann an den GetAsyncEnumerator des umschlossenen Enumerables übergibt, wenn der GetAsyncEnumerator der zurückgegebenen Struktur aufgerufen wird (wobei das Token ignoriert wird). Sie können auch einfach das CancellationToken verwenden, das im Textkörper von foreach enthalten ist.
  • Falls/wenn Abfrageausdrücke unterstützt werden, wie würde das an CancellationToken oder GetEnumerator übergebene MoveNextAsync an die einzelnen Klauseln übergeben werden? Am einfachsten wäre es, wenn die Klausel es erfassen würde. In diesem Fall wird das an GetAsyncEnumerator/MoveNextAsync übergebene Token ignoriert.

Eine frühere Version dieses Dokuments empfahl (1), aber wir sind seitdem zu (4) gewechselt.

Die beiden Hauptprobleme mit (1):

  • Producer von abbruchfähigen Enumerables müssen einige Textbausteine implementieren und können die Unterstützung des Compilers für asynchrone Iteratoren nur nutzen, um eine IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken)-Methode zu implementieren.
  • Wahrscheinlich wären viele Producer versucht, stattdessen einfach einen CancellationToken-Parameter zu ihrer asynchronen Enumerable-Signatur hinzuzufügen, was Consumer daran hindern würde, das gewünschte Abbruchtoken zu übergeben, wenn sie einen IAsyncEnumerable-Typ erhalten.

Zwei wichtige Verbrauchsszenarien müssen berücksichtigt werden:

  1. await foreach (var i in GetData(token)) ..., wobei der Consumer die Async-Iteratormethode aufruft,
  2. await foreach (var i in givenIAsyncEnumerable.WithCancellation(token)) ..., wobei der Consumer mit einer bestimmten IAsyncEnumerable-Instanz arbeitet.

Ein vernünftiger Kompromiss zur Unterstützung beider Szenarien auf eine Art und Weise, die sowohl für Producer als auch für Consumer von asynchronen Datenströmen geeignet ist, besteht darin, einen speziell kommentierten Parameter in der Async-Iteratormethode zu verwenden. Das [EnumeratorCancellation]-Attribut wird zu diesem Zweck verwendet. Wenn Sie dieses Attribut auf einen Parameter setzen, wird dem Compiler mitgeteilt, dass bei einer Tokenübergabe an die GetAsyncEnumerator-Methode dieses Tokens anstelle des ursprünglich an den Parameter übergebenen Werts verwendet werden soll.

Gehen Sie von IAsyncEnumerable<int> GetData([EnumeratorCancellation] CancellationToken token = default) aus. Der Implementierer dieser Methode kann einfach den Parameter im Methodenkörper verwenden. Der Verbraucher kann entweder die oben genannten Verbrauchsmuster verwenden:

  1. Wenn Sie GetData(token) verwenden, wird das Token in dem asynchronen Enumerable gespeichert und bei der Iteration verwendet.
  2. Wenn Sie givenIAsyncEnumerable.WithCancellation(token) verwenden, wird das an GetAsyncEnumerator übergebene Token alle in dem asynchronen Enumerable gespeicherten Token ersetzen.

foreach

foreach wird erweitert, um IAsyncEnumerable<T> zusätzlich zu dem bereits unterstützten IEnumerable<T> zu unterstützen. Außerdem wird es das Äquivalent von IAsyncEnumerable<T> als Muster unterstützen, wenn die relevanten Member öffentlich verfügbar gemacht werden, und andernfalls auf die direkte Verwendung der Schnittstelle zurückgreifen, um strukturbasierte Erweiterungen zu ermöglichen, die die Zuweisung sowie die Verwendung alternativer awaitables als Rückgabetypen von MoveNextAsync und DisposeAsync vermeiden.

Syntax

Die Syntax lautet .

foreach (var i in enumerable)

C# wird enumerable weiterhin als synchrones Enumerable behandeln. Selbst wenn die relevanten APIs für asynchrone Enumerables verfügbar gemacht werden (durch Verfügbarmachen des Musters oder Implementierung der Schnittstelle), werden somit nur die synchronen APIs berücksichtigt.

Um zu erzwingen, dass foreach stattdessen nur die asynchronen APIs berücksichtigt, wird await wie folgt eingefügt:

await foreach (var i in enumerable)

Es wird keine Syntax bereitgestellt, die die Verwendung der asynchronen oder der synchronen APIs unterstützt; der Entwickler muss je nach verwendeter Syntax wählen.

Semantik

Die Kompilierungszeitverarbeitung einer await foreach-Anweisung bestimmt zuerst den Sammlungstyp, den Enumeratortyp und den Iterationstyp des Ausdrucks (sehr ähnlich wie https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/statements.md#1395-the-foreach-statement). Diese Festlegung erfolgt wie folgt:

  • Wenn der Typ X des Ausdrucks dynamic oder ein Arraytyp ist, wird ein Fehler erzeugt, und es werden keine weiteren Schritte ausgeführt.
  • Ermitteln Sie andernfalls, ob der Typ X über eine geeignete GetAsyncEnumerator-Methode verfügt:
    • Führen Sie die Membersuche für den Typ X mit dem Bezeichner GetAsyncEnumerator und ohne Typargumente durch. Wenn die Membersuche keine Übereinstimmung liefert oder eine Mehrdeutigkeit vorliegt oder eine Übereinstimmung liefert, die keine Methodengruppe ist, suchen Sie nach einer aufzählbaren Schnittstelle, wie unten beschrieben.
    • Führen Sie die Überladungsauflösung mithilfe der resultierenden Methodengruppe und einer leeren Argumentliste aus. Wenn die Überladungsauflösung zu keiner anwendbaren Methode führt, eine Mehrdeutigkeit verursacht oder lediglich die beste Methode ergibt, die jedoch entweder statisch oder nicht öffentlich ist, prüfen Sie auf eine aufzählbare Schnittstelle, wie unten beschrieben.
    • Wenn der Rückgabetyp E der GetAsyncEnumerator-Methode weder einen Klassentyp noch einen Struktur- oder Schnittstellentyp darstellt, wird ein Fehler ausgegeben, und es werden keine weiteren Schritte ausgeführt.
    • Membersuche wird bei E mit dem Bezeichner Current und ohne Typargumente durchgeführt. Wenn die Membersuche keine Übereinstimmung ergibt, das Ergebnis ein Fehler ist oder es sich bei dem Ergebnis nicht um eine öffentliche Instanz-Eigenschaft handelt, die das Lesen zulässt, wird ein Fehler erzeugt, und es werden keine weiteren Schritte unternommen.
    • Membersuche wird bei E mit dem Bezeichner MoveNextAsync und ohne Typargumente durchgeführt. Wenn die Membersuche keine Übereinstimmung produziert, führt dies zu einem Fehler oder wenn das Ergebnis etwas anderes als eine Methodengruppe ist, führt dies zu einem Fehler, und es werden keine weiteren Schritte unternommen.
    • Die Auflösung von Überladungen wird für die Methodengruppe mit einer leeren Argumentliste durchgeführt. Wenn die Überladungsauflösung zu keiner anwendbaren Methode, zu einer Mehrdeutigkeit oder zu einer einzigen besten Methode führt, die aber entweder statisch oder nicht öffentlich ist, oder der Rückgabetyp nicht in bool „awaitable“ ist, wird ein Fehler erzeugt, und es werden keine weiteren Schritte ausgeführt.
    • Der Sammlungstyp ist X, der Enumeratortyp ist E, und der Iterationstyp ist der Typ der Current-Eigenschaft.
  • Suchen Sie andernfalls nach einer aufzählbaren Schnittstelle:
    • Wenn es unter allen Typen Tᵢ, für die eine implizite Konvertierung von X in IAsyncEnumerable<ᵢ>vorhanden ist, einen eindeutigen Typ T gibt, sodass T nicht dynamisch ist und für alle anderen Tᵢ eine implizite Konvertierung von IAsyncEnumerable<T> in IAsyncEnumerable<Tᵢ>vorhanden ist, dann ist der Sammlungstyp die Schnittstelle IAsyncEnumerable<T>, der Enumerationstyp die Schnittstelle IAsyncEnumerator<T>und der Iterationstyp T.
    • Andernfalls wird bei mehr als einem solchen Typ T ein Fehler ausgeben, und es werden keine weiteren Schritte ausgeführt.
  • Andernfalls wird ein Fehler ausgegeben, und es werden keine weiteren Schritte ausgeführt.

Die oben genannten Schritte führen, wenn erfolgreich, eindeutig zu einem Sammlungstyp C, Enumeratortyp E und Iterationstyp T.

await foreach (V v in x) «embedded_statement»

wird dann erweitert auf:

{
    E e = ((C)(x)).GetAsyncEnumerator();
    try {
        while (await e.MoveNextAsync()) {
            V v = (V)(T)e.Current;
            «embedded_statement»
        }
    }
    finally {
        ... // Dispose e
    }
}

Der Text des finally Blocks wird mit den folgenden Schritten konstruiert:

  • Wenn der Typ E über eine geeignete DisposeAsync-Methode verfügt:
    • Führen Sie die Membersuche für den Typ E mit dem Bezeichner DisposeAsync und ohne Typargumente durch. Wenn die Membersuche keine Übereinstimmung zurückgibt, eine Mehrdeutigkeit vorliegt oder eine Übereinstimmung zurückgibt, die keine Methodengruppe ist, suchen Sie nach einer Entsorgungsschnittstelle, wie unten beschrieben.
    • Führen Sie die Überladungsauflösung mithilfe der resultierenden Methodengruppe und einer leeren Argumentliste aus. Wenn die Überladungsauflösung zu keinen anwendbaren Methoden führt, eine Mehrdeutigkeit erzeugt oder lediglich eine einzelne beste Methode zurückgibt, die jedoch entweder statisch oder nicht öffentlich ist, prüfen Sie auf die Entsorgungsschnittstelle, wie unten beschrieben.
    • Wenn der Rückgabetyp der Methode DisposeAsync nicht „awaitable“ ist, wird ein Fehler erzeugt. Es werden keine weiteren Schritte ausgeführt.
    • Die Klausel finally wird auf das semantische Äquivalent des Folgenden erweitert:
      finally {
          await e.DisposeAsync();
      }
    
  • Andernfalls, wenn eine implizite Konvertierung von E in die Schnittstelle System.IAsyncDisposable vorhanden ist, dann
    • Wenn E ein nicht-nullbarer Werttyp ist, wird die finally-Klausel auf ihre semantische Entsprechung erweitert.
      finally {
          await ((System.IAsyncDisposable)e).DisposeAsync();
      }
    
    • Andernfalls wird die Klausel finally auf das semantische Äquivalent des Folgenden erweitert:
      finally {
          System.IAsyncDisposable d = e as System.IAsyncDisposable;
          if (d != null) await d.DisposeAsync();
      }
      
      Wenn E jedoch ein Werttyp ist oder ein Typparameter, der zu einem Werttyp instanziiert wurde, verursacht die Konvertierung von e in System.IAsyncDisposable kein Boxing.
  • Andernfalls wird die Klausel finally auf einen leeren Block erweitert:
    finally {
    }
    

ConfigureAwait

Diese musterbasierte Kompilierung ermöglicht die Verwendung von ConfigureAwait für alle Awaits über die Erweiterungsmethode ConfigureAwait:

await foreach (T item in enumerable.ConfigureAwait(false))
{
   ...
}

Dies basiert auf Typen, die wir auch zu .NET hinzufügen werden, wahrscheinlich zu System.Threading.Tasks.Extensions.dll:

// Approximate implementation, omitting arg validation and the like
namespace System.Threading.Tasks
{
    public static class AsyncEnumerableExtensions
    {
        public static ConfiguredAsyncEnumerable<T> ConfigureAwait<T>(this IAsyncEnumerable<T> enumerable, bool continueOnCapturedContext) =>
            new ConfiguredAsyncEnumerable<T>(enumerable, continueOnCapturedContext);

        public struct ConfiguredAsyncEnumerable<T>
        {
            private readonly IAsyncEnumerable<T> _enumerable;
            private readonly bool _continueOnCapturedContext;

            internal ConfiguredAsyncEnumerable(IAsyncEnumerable<T> enumerable, bool continueOnCapturedContext)
            {
                _enumerable = enumerable;
                _continueOnCapturedContext = continueOnCapturedContext;
            }

            public ConfiguredAsyncEnumerator<T> GetAsyncEnumerator() =>
                new ConfiguredAsyncEnumerator<T>(_enumerable.GetAsyncEnumerator(), _continueOnCapturedContext);

            public struct ConfiguredAsyncEnumerator<T>
            {
                private readonly IAsyncEnumerator<T> _enumerator;
                private readonly bool _continueOnCapturedContext;

                internal ConfiguredAsyncEnumerator(IAsyncEnumerator<T> enumerator, bool continueOnCapturedContext)
                {
                    _enumerator = enumerator;
                    _continueOnCapturedContext = continueOnCapturedContext;
                }

                public ConfiguredValueTaskAwaitable<bool> MoveNextAsync() =>
                    _enumerator.MoveNextAsync().ConfigureAwait(_continueOnCapturedContext);

                public T Current => _enumerator.Current;

                public ConfiguredValueTaskAwaitable DisposeAsync() =>
                    _enumerator.DisposeAsync().ConfigureAwait(_continueOnCapturedContext);
            }
        }
    }
}

Beachten Sie, dass dieser Ansatz nicht die Verwendung von ConfigureAwait mit musterbasierten Enumerationen ermöglicht. Andererseits wird ConfigureAwait derzeit bereits lediglich als Erweiterung für Task/Task<T>/ValueTask/ValueTask<T> verfügbar gemacht wird und nicht auf beliebige Dinge mit dem Typ „awaitable“ angewendet werden kann. Dies wäre nur bei Anwendung auf Aufgaben sinnvoll (da ein Verhalten gesteuert wird, das in der Fortsetzungsunterstützung von Aufgaben implementiert wird). Daher ist dies nicht sinnvoll bei Verwendung eines Musters, dessen Objekte vom Typ „awaitable“ möglicherweise keine Aufgaben sind. Wenn Objekte mit dem Typ „awaitable“ zurückgegeben werden, kann in diesen erweiterten Szenarien ein eigenes benutzerdefiniertes Verhalten bereitgestellt werden.

(Wenn wir einen Weg finden, eine Lösung auf Bereichs- oder Assemblyebene ConfigureAwait zu unterstützen, ist dies nicht erforderlich.)

Asynchrone Iteratoren

Die Sprache / der Compiler wird die Erstellung von IAsyncEnumerable<T> und IAsyncEnumerator<T> zusätzlich zu ihrer Verwendung unterstützen. Derzeit unterstützt die Sprache das Schreiben von Iteratoren wie z. B.:

static IEnumerable<int> MyIterator()
{
    try
    {
        for (int i = 0; i < 100; i++)
        {
            Thread.Sleep(1000);
            yield return i;
        }
    }
    finally
    {
        Thread.Sleep(200);
        Console.WriteLine("finally");
    }
}

Allerdings kann await nicht im Text dieser Iteratoren verwendet werden. Wir werden diese Unterstützung hinzufügen.

Syntax

Die vorhandene Sprachunterstützung für Iteratoren leitet die Iterator-Eigenschaft der Methode abhängig davon ab, ob sie yield enthält. Das Gleiche gilt für asynchrone Iteratoren. Diese asynchronen Iteratoren werden von synchronen Iteratoren abgegrenzt und unterschieden, indem der Signatur async hinzugefügt wird. Sie müssen außerdem entweder IAsyncEnumerable<T> oder IAsyncEnumerator<T> als Rückgabetyp aufweisen. Das Beispiel oben könnte z. B. folgendermaßen als asynchroner Iterator geschrieben werden:

static async IAsyncEnumerable<int> MyIterator()
{
    try
    {
        for (int i = 0; i < 100; i++)
        {
            await Task.Delay(1000);
            yield return i;
        }
    }
    finally
    {
        await Task.Delay(200);
        Console.WriteLine("finally");
    }
}

Berücksichtigte Alternativen:

  • async nicht in der Signatur verwenden: Die Verwendung von async ist wahrscheinlich eine technische Anforderung des Compilers, da so ermittelt wird, ob await in diesem Kontext gültig ist. Selbst wenn dies nicht erforderlich ist, darf await nur in Methoden verwendet werden, die als async gekennzeichnet sind, und es ist wichtig, die Konsistenz beizubehalten.
  • Unterstützung benutzerdefinierter Builder für IAsyncEnumerable<T>: Wir könnten dies für die Zukunft in Betracht ziehen. Der Mechanismus ist jedoch kompliziert und wir unterstützen dies nicht für die synchronen Gegenstücke.
  • Verwendung eines iterator-Schlüsselworts in der Signatur: Asynchrone Iteratoren würden async iterator in der Signatur verwenden. yield könnten nur in async-Methoden verwendet werden, die iterator enthalten. iterator wäre in diesem Fall optional für synchrone Iteratoren. Je nach Perspektive hat dies den Vorteil, dass es durch die Signatur der Methode sehr deutlich wird, ob yield zulässig ist und ob die Methode tatsächlich Instanzen vom Typ IAsyncEnumerable<T> zurückgeben soll, anstatt dass der Compiler eine erstellt, basierend darauf, ob der Code yield verwendet oder nicht. Dies unterscheidet sich jedoch von synchronen Iteratoren, die dies nicht erfordern und für die es auch nicht durchgesetzt werden kann, dies zu erfordern. Außerdem mögen manche Entwicklungsteams die zusätzliche Syntax nicht. Wenn wir dies von Grund auf neu entwerfen würden, würden wir dies wahrscheinlich obligatorisch machen, aber an diesem Punkt ist es viel wertvoller, asynchrone Iteratoren nah an synchronen Iteratoren zu halten.

LINQ

Es gibt über 200 Überladungen von Methoden für die System.Linq.Enumerable-Klasse, die alle auf der Grundlage von IEnumerable<T> arbeiten. Einige von ihnen akzeptieren IEnumerable<T>, einige produzieren IEnumerable<T>, und viele tun beides. Das Hinzufügen von LINQ-Unterstützung für IAsyncEnumerable<T> würde wahrscheinlich das Duplizieren aller dieser Überladungen bedeuten, für weitere etwa 200 Fälle. Und da IAsyncEnumerator<T> in der asynchronen Welt wahrscheinlich häufiger als eigenständige Entität vorkommt als IEnumerator<T> in der synchronen Welt, könnten wir möglicherweise weitere ca. 200 Überladungen benötigen, die mit IAsyncEnumerator<T> funktionieren. Darüber hinaus behandelt eine große Anzahl der Überladungen Prädikate (z. B. Where, die eine Func<T, bool> akzeptiert), und es kann wünschenswert sein, IAsyncEnumerable<T>-basierte Überladungen zu haben, die sowohl synchrone als auch asynchrone Prädikate behandeln (z. B. Func<T, ValueTask<bool>> zusätzlich zu Func<T, bool>). Dies gilt zwar nicht für alle jetzt ca. 400 neuen Überladungen, aber eine grobe Schätzung geht davon aus, dass es für die Hälfte gilt, was weitere rund 200 Überladungen bedeutet, für insgesamt ca. 600 neue Methoden.

Das ist eine überwältigende Anzahl von APIs, mit das Potenzial für noch mehr, wenn Erweiterungsbibliotheken wie Interactive Extensions (Ix) in Betracht gezogen werden. Jedoch weist Ix bereits eine Implementierung vieler dieser APIs auf, und es scheint keinen wichtigen Grund zu geben, diese Arbeit zu duplizieren. Stattdessen sollte die Community Hilfe beim Verbessern von lx erhalten. Darüber hinaus sollte lx für Entwicklungsteams empfohlen werden, die LINQ mit IAsyncEnumerable<T> verwenden möchten.

Dann gibt es auch das Problem der Syntax für das Verständnis von Abfragen. Die musterbasierte Natur von Abfrageausdrücken würde es ihnen ermöglichen, mit einigen Operatoren „einfach zu funktionieren“, z. B., wenn Ix die folgenden Methoden bereitstellt:

public static IAsyncEnumerable<TResult> Select<TSource, TResult>(this IAsyncEnumerable<TSource> source, Func<TSource, TResult> func);
public static IAsyncEnumerable<T> Where(this IAsyncEnumerable<T> source, Func<T, bool> func);

dann wird dieser C#-Code einfach funktionieren.

IAsyncEnumerable<int> enumerable = ...;
IAsyncEnumerable<int> result = from item in enumerable
                               where item % 2 == 0
                               select item * 2;

Es gibt jedoch keine Syntax für das Verständnis von Abfragen, die die Verwendung von await in den Klauseln unterstützt, also wenn Ix Folgendes hinzufügt:

public static IAsyncEnumerable<TResult> Select<TSource, TResult>(this IAsyncEnumerable<TSource> source, Func<TSource, ValueTask<TResult>> func);

dann würde dies einfach funktionieren:

IAsyncEnumerable<string> result = from url in urls
                                  where item % 2 == 0
                                  select SomeAsyncMethod(item);

async ValueTask<int> SomeAsyncMethod(int item)
{
    await Task.Yield();
    return item * 2;
}

Es gäbe jedoch keine Möglichkeit, dies inline mit der await in der select-Klausel zu schreiben. Als separate Maßnahme könnten wir uns mit dem Hinzufügen von async { ... }-Ausdrücken zu der Sprache befassen, wodurch wir die Verwendung zum Verständnis von Abfragen ermöglichen könnten, und der obige Ausdruck könnte stattdessen wie folgt geschrieben werden:

IAsyncEnumerable<int> result = from item in enumerable
                               where item % 2 == 0
                               select async
                               {
                                   await Task.Yield();
                                   return item * 2;
                               };

oder um await direkt in Ausdrücken verwenden zu können, z. B. durch Unterstützung von async from. Es ist jedoch unwahrscheinlich, dass ein Design hier auf die eine oder andere Weise Auswirkungen auf den Rest des Funktionsumfangs hat, und es ist kein besonders wertvolles Vorhaben, in das man derzeit investieren sollte. Daher ist der Vorschlag, derzeit nichts Weiteres zu unternehmen.

Integration in andere asynchrone Frameworks

Die Integration in IObservable<T> und andere asynchrone Frameworks (z. B. reaktive Streams) erfolgt auf Bibliotheks- und nicht auf Sprachebene. Beispielsweise können alle Daten aus einer IAsyncEnumerator<T> einfach durch await foreach-ing über den Enumerator und durch OnNext-ing der Daten zum Observer in einem IObserver<T> veröffentlicht werden, sodass eine AsObservable<T>-Erweiterungsmethode möglich ist. Die Verwendung eines IObservable<T> in einem await foreach erfordert das Puffern der Daten (falls ein anderes Element eingefügt wird, während das vorherige Element noch bearbeitet wird), aber ein solcher Push-Pull-Adapter kann problemlos implementiert werden, um das Pulling eines IObservable<T> mit einem IAsyncEnumerator<T> zu ermöglichen. Etc. Rx/Ix bieten bereits Prototypen solcher Implementierungen, und Bibliotheken wie https://github.com/dotnet/corefx/tree/master/src/System.Threading.Channels bieten verschiedene Arten von Pufferdatenstrukturen. Die Sprache muss in dieser Phase nicht einbezogen werden.