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.
Diskussionen zu diesem Thema
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 await
s 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
akzeptiertCancellationToken
: 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. DasCancellationToken
, das die Unterbrechung der Arbeit verursacht hat, ist in der Regel auch das Token, das anDisposeAsync
übergeben wird. Damit wirdDisposeAsync
wertlos, da der Abbruch der Arbeit dazu führen würde, dassDisposeAsync
zu einer Nulloperation wird. Wer es vermeiden möchte, durch das Warten auf die Beseitigung blockiert zu werden, kann das Warten auf die resultierendeValueTask
vermeiden oder nur für einen gewissen Zeitraum darauf warten.DisposeAsync
gibtTask
zurück: Da jetzt eine nicht generischeValueTask
vorhanden ist und aus einerIValueTaskSource
erstellt werden kann, ermöglicht die Rückgabe einerValueTask
vonDisposeAsync
die Wiederverwendung eines bestehenden Objekts als Versprechen, das die letztendliche asynchrone Fertigstellung vonDisposeAsync
darstellt. Dadurch kann eineTask
-Zuordnung gespart werden, fallsDisposeAsync
asynchron abgeschlossen wird.- Konfiguration von
DisposeAsync
mit einembool continueOnCapturedContext
(ConfigureAwait
): Es kann zwar Probleme in Bezug darauf geben, wie ein solches Konzeptusing
,foreach
und anderen Sprachkonstrukten, die dies nutzen, verfügbar gemacht wird, aus der Sicht der Benutzeroberfläche führt es jedoch keinawait
durch und es gibt nichts zu konfigurieren ... Nutzer derValueTask
können es beliebig verwenden. IAsyncDisposable
erbtIDisposable
: Da nur das eine oder das andere verwendet werden soll, ist es nicht sinnvoll, Typen zur Implementierung von beiden zu zwingen.-
IDisposableAsync
anstelle vonIAsyncDisposable
: 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 vonTask<bool>
würde die Nutzung eines zwischengespeicherten Aufgabenobjekts zur Darstellung synchroner, erfolgreicherMoveNextAsync
-Aufrufe unterstützen. Es wäre jedoch weiterhin eine Zuordnung für den asynchronen Abschluss erforderlich. Durch die Rückgabe vonValueTask<bool>
wird es dem Enumeratorobjekt ermöglicht, selbstIValueTaskSource<bool>
zu implementieren und als Unterstützung für das vonValueTask<bool>
zurückgegebeneMoveNextAsync
zu dienen. Dadurch kann der Aufwand deutlich reduziert werden.ValueTask<(bool, T)> MoveNextAsync();
: Nicht nur schwieriger zu verwenden, sondern bedeutet auch, dassT
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);
: Dasout
-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>
implementiertIAsyncDisposable
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 alspublic 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
undCurrent
getrennt sind, sodass eine Implementierung ihre Nutzung nicht atomisch machen kann. Im Gegensatz dazu bietet dieser Ansatz eine einzelne MethodeTryGetNext
, 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 beiWaitForNextAsync
/TryGetNext
im besten Fall die meisten Iterationen synchron abgeschlossen werden. Hierdurch wird eine enge innere Schleife mitTryGetNext
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:
-
IAsyncEnumerable<T>
/IAsyncEnumerator<T>
sind abbruchunabhängig:CancellationToken
taucht nirgendwo auf. Ein Abbruch erfolgt durch logisches Einbinden desCancellationToken
in das Enumerable und/oder den Enumerator auf die jeweils geeignete Weise, z. B. beim Aufruf eines Iterators, bei der Übergabe desCancellationToken
als Argument an die Iteratormethode und bei der Verwendung im Textkörper des Iterators, wie bei jedem anderen Parameter. IAsyncEnumerator<T>.GetAsyncEnumerator(CancellationToken)
: Sie übergeben einCancellationToken
anGetAsyncEnumerator
, und nachfolgendeMoveNextAsync
-Vorgänge berücksichtigen es so gut wie möglich.-
IAsyncEnumerator<T>.MoveNextAsync(CancellationToken)
: Sie übergeben einCancellationToken
an jeden einzelnenMoveNextAsync
-Aufruf. - 1 && 2: Sie betten
CancellationToken
s in Ihr Enumerable/Ihren Enumerator ein und übergebenCancellationToken
s anGetAsyncEnumerator
. - 1 && 3: Sie betten
CancellationToken
s in Ihr Enumerable/Ihren Enumerator ein und übergebenCancellationToken
s anMoveNextAsync
.
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 anGetAsyncEnumerator
übergeben wird, in den Textkörper des Iterators? Wir könnten ein neuesiterator
-Schlüsselwort verfügbar machen, mit dem Sie auf das anCancellationToken
übergebeneGetEnumerator
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 auchGetAsyncEnumerator
darauf aufruft. In diesem Fall kann dasCancellationToken
einfach als Argument an die Methode übergeben werden. - Wie gelangt ein
CancellationToken
, das anMoveNextAsync
übergeben wird, in den Textkörper der Methode? Dies ist noch schlechter, denn wenn es von einem lokaleniterator
-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 jedemMoveNextAsync
-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 einCancellationToken
zugeordnet wird, müssen wir entweder a) einforeach
ing ü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) dasCancellationToken
ohnehin in das Enumerable einbetten, indem wir eineWithCancellation
-Erweiterungsmethode vonIAsyncEnumerable<T>
verwenden, die das bereitgestellte Token speichert und dann an denGetAsyncEnumerator
des umschlossenen Enumerables übergibt, wenn derGetAsyncEnumerator
der zurückgegebenen Struktur aufgerufen wird (wobei das Token ignoriert wird). Sie können auch einfach dasCancellationToken
verwenden, das im Textkörper von foreach enthalten ist. - Falls/wenn Abfrageausdrücke unterstützt werden, wie würde das an
CancellationToken
oderGetEnumerator
übergebeneMoveNextAsync
an die einzelnen Klauseln übergeben werden? Am einfachsten wäre es, wenn die Klausel es erfassen würde. In diesem Fall wird das anGetAsyncEnumerator
/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 einenIAsyncEnumerable
-Typ erhalten.
Zwei wichtige Verbrauchsszenarien müssen berücksichtigt werden:
await foreach (var i in GetData(token)) ...
, wobei der Consumer die Async-Iteratormethode aufruft,await foreach (var i in givenIAsyncEnumerable.WithCancellation(token)) ...
, wobei der Consumer mit einer bestimmtenIAsyncEnumerable
-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:
- Wenn Sie
GetData(token)
verwenden, wird das Token in dem asynchronen Enumerable gespeichert und bei der Iteration verwendet. - Wenn Sie
givenIAsyncEnumerable.WithCancellation(token)
verwenden, wird das anGetAsyncEnumerator
ü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 Ausdrucksdynamic
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 geeigneteGetAsyncEnumerator
-Methode verfügt:- Führen Sie die Membersuche für den Typ
X
mit dem BezeichnerGetAsyncEnumerator
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
derGetAsyncEnumerator
-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 BezeichnerCurrent
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 BezeichnerMoveNextAsync
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 istE
, und der Iterationstyp ist der Typ derCurrent
-Eigenschaft.
- Führen Sie die Membersuche für den Typ
- Suchen Sie andernfalls nach einer aufzählbaren Schnittstelle:
- Wenn es unter allen Typen
Tᵢ
, für die eine implizite Konvertierung vonX
inIAsyncEnumerable<ᵢ>
vorhanden ist, einen eindeutigen TypT
gibt, sodassT
nicht dynamisch ist und für alle anderenTᵢ
eine implizite Konvertierung vonIAsyncEnumerable<T>
inIAsyncEnumerable<Tᵢ>
vorhanden ist, dann ist der Sammlungstyp die SchnittstelleIAsyncEnumerable<T>
, der Enumerationstyp die SchnittstelleIAsyncEnumerator<T>
und der IterationstypT
. - Andernfalls wird bei mehr als einem solchen Typ
T
ein Fehler ausgeben, und es werden keine weiteren Schritte ausgeführt.
- Wenn es unter allen Typen
- 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 geeigneteDisposeAsync
-Methode verfügt:- Führen Sie die Membersuche für den Typ
E
mit dem BezeichnerDisposeAsync
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(); }
- Führen Sie die Membersuche für den Typ
- Andernfalls, wenn eine implizite Konvertierung von
E
in die SchnittstelleSystem.IAsyncDisposable
vorhanden ist, dann- Wenn
E
ein nicht-nullbarer Werttyp ist, wird diefinally
-Klausel auf ihre semantische Entsprechung erweitert.
finally { await ((System.IAsyncDisposable)e).DisposeAsync(); }
- Andernfalls wird die Klausel
finally
auf das semantische Äquivalent des Folgenden erweitert:
Wennfinally { System.IAsyncDisposable d = e as System.IAsyncDisposable; if (d != null) await d.DisposeAsync(); }
E
jedoch ein Werttyp ist oder ein Typparameter, der zu einem Werttyp instanziiert wurde, verursacht die Konvertierung vone
inSystem.IAsyncDisposable
kein Boxing.
- Wenn
- 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 vonasync
ist wahrscheinlich eine technische Anforderung des Compilers, da so ermittelt wird, obawait
in diesem Kontext gültig ist. Selbst wenn dies nicht erforderlich ist, darfawait
nur in Methoden verwendet werden, die alsasync
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ürdenasync iterator
in der Signatur verwenden.yield
könnten nur inasync
-Methoden verwendet werden, dieiterator
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, obyield
zulässig ist und ob die Methode tatsächlich Instanzen vom TypIAsyncEnumerable<T>
zurückgeben soll, anstatt dass der Compiler eine erstellt, basierend darauf, ob der Codeyield
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.
C# feature specifications