Strumienie asynchroniczne
Notatka
Ten artykuł jest specyfikacją funkcji. Specyfikacja służy jako dokument projektowy dla funkcji. Zawiera proponowane zmiany specyfikacji wraz z informacjami wymaganymi podczas projektowania i opracowywania funkcji. Te artykuły są publikowane do momentu sfinalizowania proponowanych zmian specyfikacji i włączenia ich do obecnej specyfikacji ECMA.
Mogą wystąpić pewne rozbieżności między specyfikacją funkcji a ukończoną implementacją. Te różnice są ujęte w odpowiednich notatkach z projektowania języka (LDM).
Więcej informacji na temat procesu wdrażania specyfikacji funkcji można znaleźć w standardzie języka C# w artykule dotyczącym specyfikacji .
Problem z mistrzem: https://github.com/dotnet/csharplang/issues/43
Streszczenie
Język C# obsługuje metody iteracyjne i metody asynchroniczne, ale nie obsługuje metody zarówno iteratora, jak i metody asynchronicznej. Powinniśmy rozwiązać ten problem, umożliwiając użycie await
w nowej formie iteratora async
, który zwraca IAsyncEnumerable<T>
lub IAsyncEnumerator<T>
, a nie IEnumerable<T>
lub IEnumerator<T>
, z możliwością zużycia IAsyncEnumerable<T>
w nowym await foreach
. Interfejs IAsyncDisposable
służy również do włączania oczyszczania asynchronicznego.
Powiązana dyskusja
Szczegółowy projekt
Interfejsy
IAsyncDisposable
Było wiele dyskusji na temat IAsyncDisposable
(np. https://github.com/dotnet/roslyn/issues/114) i czy jest to dobry pomysł. Jednak jest to wymagana koncepcja, aby dodać obsługę iteratorów asynchronicznych. Ponieważ bloki finally
mogą zawierać elementy await
, a bloki finally
muszą być uruchamiane jako część procesu usuwania iteratorów, potrzebujemy asynchronicznego usuwania. Jest to również ogólnie przydatne w sytuacjach, gdy sprzątanie zasobów może zająć jakiś czas, np. zamykanie plików (wymagających opróżnienia), wyrejestrowywanie wywołań zwrotnych i zapewniając sposób, aby wiedzieć, kiedy wyrejestrowanie zostało zakończone itp.
Następujący interfejs jest dodawany do podstawowych bibliotek platformy .NET (np. System.Private.CoreLib / System.Runtime):
namespace System
{
public interface IAsyncDisposable
{
ValueTask DisposeAsync();
}
}
Podobnie jak w przypadku Dispose
, wywoływanie DisposeAsync
wiele razy jest dopuszczalne, a kolejne wywołania po pierwszym powinny być traktowane jako pozorne operacje, zwracając synchronicznie zakończone pomyślnie zadanie (DisposeAsync
nie musi być bezpieczne dla wątków i nie musi obsługiwać współbieżnych wywołań). Ponadto, typy mogą implementować zarówno IDisposable
, jak i IAsyncDisposable
, a jeśli tak jest, możliwe jest wywołanie Dispose
, a następnie DisposeAsync
lub odwrotnie. Jednak tylko pierwsze wywołanie powinno mieć znaczenie, a każde kolejne powinno być no-op. W związku z tym, jeśli typ implementuje oba, użytkownicy są zachęcani do wywoływania raz i tylko raz bardziej odpowiedniej metody w zależności od kontekstu, Dispose
w kontekstach synchronicznych i DisposeAsync
w asynchronicznych.
(Sposób, w jaki IAsyncDisposable
wchodzi w interakcję z using
, to osobna dyskusja. A omówienie interakcji z foreach
następuje w dalszej części tego dokumentu).
Rozważane alternatywy:
-
DisposeAsync
akceptowanieCancellationToken
: chociaż teoretycznie ma sens, że wszystko, co asynchroniczne można anulować, usuwanie polega na czyszczeniu, zamykaniu rzeczy, zwalnianiu zasobów itp., co na ogół nie jest czymś, co powinno zostać anulowane; czyszczenie jest nadal ważne w przypadku pracy, która została anulowana. Te sameCancellationToken
, które spowodowały anulowanie rzeczywistej pracy, zwykle byłyby tym samym tokenem przekazywanym doDisposeAsync
, co czyniDisposeAsync
bezwartościowe, ponieważ anulowanie pracy spowodowałoby, żeDisposeAsync
być no-op. Jeśli ktoś chce uniknąć zablokowania podczas oczekiwania na dyspozycję, może unikać czekania na wynikowyValueTask
lub czekać na niego tylko przez pewien czas. -
DisposeAsync
zwracanieTask
: teraz, gdy istnieje niegenerycznyValueTask
i można go skonstruować zIValueTaskSource
, zwracającValueTask
zDisposeAsync
umożliwia ponowne użycie istniejącego obiektu jako obietnicy reprezentującej ostateczne asynchroniczne ukończenieDisposeAsync
, zapisywanie alokacjiTask
w przypadku, gdyDisposeAsync
kończy się asynchronicznie. -
Konfigurowanie
DisposeAsync
za pomocąbool continueOnCapturedContext
(ConfigureAwait
): Chociaż mogą występować problemy związane z tym, jak taka koncepcja jest przedstawionausing
,foreach
i innym konstrukcjom językowym, które z tego korzystają, w perspektywie interfejsu nie wykonuje żadnegoawait
i nie ma nic do skonfigurowania... użytkownicyValueTask
mogą z niego korzystać według własnego uznania. -
IAsyncDisposable
dziedziczyIDisposable
: ponieważ należy korzystać tylko z jednego lub drugiego, nie ma sensu zmuszać do implementacji obu typów. -
IDisposableAsync
zamiastIAsyncDisposable
: Przestrzegaliśmy nazw elementów/typów jako "asynchronicznych elementów", podczas gdy operacje są "wykonywane asynchroniczne", więc typy mają "Asynchroniczne" jako prefiks i metody mają "Async" jako sufiks.
IAsyncEnumerable/IAsyncEnumerator
Dwa interfejsy są dodawane do podstawowych bibliotek platformy .NET:
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; }
}
}
Typowe użycie (bez dodatkowych funkcji językowych) wyglądałoby następująco:
IAsyncEnumerator<T> enumerator = enumerable.GetAsyncEnumerator();
try
{
while (await enumerator.MoveNextAsync())
{
Use(enumerator.Current);
}
}
finally { await enumerator.DisposeAsync(); }
Opcje odrzucone po rozważeniu:
-
Task<bool> MoveNextAsync(); T current { get; }
: UżycieTask<bool>
byłoby pomocne, gdyby pozwalało używać buforowanego obiektu zadania do przedstawiania synchronicznych, pomyślnych wywołańMoveNextAsync
, ale przydział pamięci nadal byłby wymagany do ukończenia asynchronicznego. ZwracającValueTask<bool>
, umożliwiamy obiektowi wyliczającemu samodzielne implementowanieIValueTaskSource<bool>
i użycie jako zaplecza dlaValueTask<bool>
zwracanego zMoveNextAsync
, co z kolei pozwala na znaczne zmniejszenie kosztów ogólnych. -
ValueTask<(bool, T)> MoveNextAsync();
: Nie tylko trudniej jest przyswoić, ale oznacza to, żeT
nie może już być kowariantna. -
ValueTask<T?> TryMoveNextAsync();
: nie kowariantny. -
Task<T?> TryMoveNextAsync();
: nie kowariantny, alokacje przy każdym wywołaniu itp. -
ITask<T?> TryMoveNextAsync();
: nie kowariantny, alokacje przy każdym wywołaniu itp. -
ITask<(bool,T)> TryMoveNextAsync();
: nie kowariantny, alokacje przy każdym wywołaniu itp. -
Task<bool> TryMoveNextAsync(out T result);
: wynikout
musi zostać ustawiony w momencie, gdy operacja zwraca wynik synchronizacyjnie, a nie wtedy, gdy asynchronicznie zakończy zadanie, co może potrwać długo w przyszłości, uniemożliwiając wówczas komunikację wyniku. -
IAsyncEnumerator<T>
nieimplementowanieIAsyncDisposable
: Moglibyśmy zdecydować się je oddzielić. Jednak w ten sposób komplikuje niektóre inne obszary propozycji, ponieważ kod musi być w stanie poradzić sobie z możliwością, że moduł wyliczający nie zapewnia usuwania, co utrudnia pisanie pomocników opartych na wzorcu. Ponadto, często zdarza się, że moduły wyliczające wymagają usunięcia (np. dowolny asynchroniczny iterator C#, który ma blok finally, większość obiektów wyliczających dane z połączenia sieciowego itp.), a jeśli nie, proste jest zaimplementowanie metody wyłącznie jakopublic ValueTask DisposeAsync() => default(ValueTask);
przy minimalnym dodatkowym obciążeniu. - _
IAsyncEnumerator<T> GetAsyncEnumerator()
: brak parametru tokenu anulowania.
W poniższej podsekcji omówiono alternatywy, które nie zostały wybrane.
Realna alternatywa:
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
jest używana w pętli wewnętrznej do korzystania z elementów z jednym wywołaniem interfejsu, o ile są one dostępne synchronicznie. Gdy następny element nie może zostać pobrany synchronicznie, zwraca wartość false i za każdym razem, gdy zwraca wartość false, obiekt wywołujący musi następnie wywołać WaitForNextAsync
, aby zaczekać na udostępnienie następnego elementu lub określić, że nigdy nie będzie innego elementu. Typowe użycie (bez dodatkowych funkcji językowych) wyglądałoby następująco:
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(); }
Zaletą jest dwojaka, z czego jedna jest niewielka, a druga znacząca.
-
drobny: umożliwia wylicznikowi obsługę wielu konsumentów. Mogą istnieć scenariusze, w których warto użyć modułu wyliczającego do obsługi wielu współbieżnych odbiorców. Nie można tego osiągnąć, gdy
MoveNextAsync
iCurrent
są oddzielone w taki sposób, że implementacja nie może sprawić, by ich użycie było atomowe. Z kolei takie podejście zapewnia jedną metodęTryGetNext
, która obsługuje przesuwanie enumeratora do przodu i uzyskiwanie następnego elementu, dzięki czemu enumerator umożliwia zapewnienie niepodzielności w razie potrzeby. Istnieje jednak prawdopodobieństwo, że takie scenariusze mogą być również włączone, dając każdemu użytkownikowi własny moduł wyliczający z udostępnionego wyliczenia. Ponadto nie chcemy wymuszać, aby każdy moduł wyliczający obsługiwał współbieżne użycie, ponieważ spowoduje to dodanie nietrygalnych obciążeń do większości przypadków, które tego nie wymagają, co oznacza, że użytkownik interfejsu zwykle nie mógł polegać na tym w żaden sposób. -
Główne: wydajność. Podejście
MoveNextAsync
/Current
wymaga dwóch wywołań interfejsu na operację, podczas gdy w najlepszym wypadku dlaWaitForNextAsync
/TryGetNext
większość iteracji kończy się synchronicznie, umożliwiając ciasną pętlę wewnętrzną zTryGetNext
, tak że mamy tylko jedno wywołanie interfejsu na operację. Może to mieć wymierny wpływ w sytuacjach, w których wywołania interfejsu dominują w obliczeniach.
Istnieją jednak nietrywialne wady, w tym znacznie większa złożoność podczas ich ręcznego używania oraz zwiększone prawdopodobieństwo wprowadzenia usterek podczas ich używania. A chociaż korzyści związane z wydajnością pojawiają się w mikrobenchmarkach, nie wierzymy, że będą one miały wpływ w znakomitej większości praktycznego użycia. Jeśli okaże się, że są, możemy wprowadzić drugi zestaw interfejsów w sposób jasny.
Opcje odrzucone po rozważeniu:
-
ValueTask<bool> WaitForNextAsync(); bool TryGetNext(out T result);
: nie można kowariantować parametrówout
. W tym miejscu występuje również niewielki wpływ (problem z ogólnym wzorcem próby), który prawdopodobnie powoduje powstanie bariery zapisu podczas wykonywania dla wyników typu odwołania.
Anulowanie
Istnieje kilka możliwych metod obsługi anulowania:
-
IAsyncEnumerable<T>
/IAsyncEnumerator<T>
są niewrażliwe na anulowanie:CancellationToken
nie pojawia się nigdzie. Anulowanie jest osiągane przez logiczne włączenieCancellationToken
do wyliczalnego i/lub enumeratora w sposób, który jest odpowiedni, np. podczas wywoływania iteratora, przekazywanieCancellationToken
jako argument do metody iteratora i używanie go w treści iteratora, tak jak każdy inny parametr. -
IAsyncEnumerator<T>.GetAsyncEnumerator(CancellationToken)
: przekazujeszCancellationToken
doGetAsyncEnumerator
, a kolejne operacjeMoveNextAsync
przestrzegają go, tak jak to możliwe. -
IAsyncEnumerator<T>.MoveNextAsync(CancellationToken)
: przekazujeszCancellationToken
do poszczególnych wywołańMoveNextAsync
. - 1 && 2: Oboje osadzacie
CancellationToken
w wyliczalnym/wyliczaczu i przekazujecieCancellationToken
doGetAsyncEnumerator
. - 1 && 3: Obojga osadzacie
CancellationToken
w swojej wyliczalnej/iteratorze i przekazujecieCancellationToken
doMoveNextAsync
.
Z czysto teoretycznej perspektywy (5) jest najbardziej niezawodny, w tym (a) MoveNextAsync
akceptowanie CancellationToken
umożliwia najbardziej precyzyjną kontrolę nad tym, co zostało anulowane, a (b) CancellationToken
to tylko każdy inny typ, który może przekazać jako argument do iteratorów, osadzony w dowolnych typach itp.
Jednak istnieje wiele problemów z tym podejściem:
- W jaki sposób
CancellationToken
przekazane doGetAsyncEnumerator
trafia do ciała iteratora? Możemy uwidocznić nowe słowo kluczoweiterator
, które można wyłączyć, aby uzyskać dostęp doCancellationToken
przekazane doGetEnumerator
, ale a) to wiele dodatkowych maszyn, b) czynimy go bardzo obywatelem pierwszej klasy i c) 99% przypadku wydaje się być tym samym kodem zarówno wywołującym iterator, jak i wywołującGetAsyncEnumerator
na nim, w tym przypadku może po prostu przekazaćCancellationToken
jako argument do metody . - W jaki sposób
CancellationToken
przekazane doMoveNextAsync
dostać się do treści metody? Jest to jeszcze bardziej problematyczne, ponieważ jeśli jest ujawniony z lokalnego obiektuiterator
, jego wartość może się zmieniać podczas różnych oczekiwań, co oznacza, że każdy kod zarejestrowany z użyciem tokena będzie musiał się z niego wyrejestrować przed oczekiwaniem, a następnie ponownie zarejestrować po. Jest to również potencjalnie dość kosztowne, aby wykonywać takie rejestrowanie i wyrejestrowanie przy każdym wywołaniuMoveNextAsync
, niezależnie od tego, czy zostało zaimplementowane przez kompilator w iteratorze, czy ręcznie przez dewelopera. - Jak deweloper anuluje pętlę
foreach
? W przypadku, gdy jest to zrobione przez przekazanieCancellationToken
do wyliczalnego/wyliczającego, następnie albo a) musimy obsługiwaćforeach
'ing over enumerators, co podnosi je do rangi pełnoprawnych jednostek, a teraz trzeba zacząć myśleć o ekosystemie zbudowanym wokół enumeratorów (np. metod LINQ) lub b) musimy nadal osadzićCancellationToken
w wyliczalnym, mając pewną metodę rozszerzającąWithCancellation
zIAsyncEnumerable<T>
, która będzie przechowywać podany token, a następnie przekaże go doGetAsyncEnumerator
zawiniętego wyliczenia, gdy zostanie wywołanaGetAsyncEnumerator
zwróconej struktury (pomijając ten token). Możesz też po prostu użyćCancellationToken
, które masz w ciele foreach. - Jeśli/kiedy są obsługiwane interpretacje zapytań, w jaki sposób
CancellationToken
dostarczone doGetEnumerator
lubMoveNextAsync
będą przekazywane do każdej klauzuli? Najprostszym sposobem byłoby po prostu, aby klauzula to przechwyciła, dzięki czemu cokolwiek token jest przekazywany doGetAsyncEnumerator
/MoveNextAsync
, jest ignorowane.
Wcześniejsza wersja tego dokumentu zalecała (1), ale od tego czasu zmieniliśmy na (4).
Dwa główne problemy z (1):
- producenci anulowalnych enumerabili muszą zaimplementować pewien kod wzorcowy i mogą korzystać tylko z obsługi kompilatora na async-iteratory, aby zaimplementować metodę
IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken)
. - jest prawdopodobne, że wielu producentów będzie kusiło, aby po prostu dodać parametr
CancellationToken
do sygnatury asynchronicznej wyliczalnej, co uniemożliwić konsumentom przekazanie żądanego tokenu anulowania, gdy otrzymają typIAsyncEnumerable
.
Istnieją dwa główne scenariusze zużycia:
-
await foreach (var i in GetData(token)) ...
, gdzie użytkownik wywołuje metodę async-iterator, -
await foreach (var i in givenIAsyncEnumerable.WithCancellation(token)) ...
, w którym konsument zajmuje się danym wystąpieniemIAsyncEnumerable
.
Uważamy, że rozsądnym kompromisem w celu obsługi obu scenariuszy w sposób wygodny dla producentów i konsumentów strumieni asynchronicznych jest użycie specjalnie oznaczonego parametru w metodzie iteratora asynchronicznego. W tym celu jest używany atrybut [EnumeratorCancellation]
. Umieszczenie tego atrybutu w parametrze informuje kompilatora, że jeśli token jest przekazywany do metody GetAsyncEnumerator
, należy użyć tego tokenu zamiast wartości pierwotnie przekazanej dla parametru.
Rozważ IAsyncEnumerable<int> GetData([EnumeratorCancellation] CancellationToken token = default)
.
Implementator tej metody może po prostu użyć parametru w treści metody.
Użytkownik może użyć powyższych wzorców zużycia:
- jeśli używasz
GetData(token)
, token zostanie zapisany w wyliczenie asynchroniczne i będzie używany w iteracji, - Jeśli używasz
givenIAsyncEnumerable.WithCancellation(token)
, token przekazany doGetAsyncEnumerator
zastąpi dowolny token zapisany w async-enumerable.
foreach
foreach
zostanie rozszerzona o obsługę IAsyncEnumerable<T>
oprócz istniejącej obsługi IEnumerable<T>
. I będzie obsługiwać odpowiednik IAsyncEnumerable<T>
jako wzorzec, pod warunkiem, że odpowiednie elementy członkowskie są publicznie dostępne; w przeciwnym razie skorzysta z bezpośredniego użycia interfejsu. Umożliwia to rozszerzenia oparte na strukturach, które unikają alokacji, oraz używanie alternatywnych typów zwracających jako typu dla MoveNextAsync
i DisposeAsync
.
Składnia
Przy użyciu składni:
foreach (var i in enumerable)
Język C# będzie nadal traktował enumerable
jako wyliczanie synchroniczne, tak że nawet jeśli udostępnia odpowiednie interfejsy API dla wyliczeń asynchronicznych (uwidaczniając wzorzec lub implementując interfejs), będzie uwzględniał tylko synchroniczne interfejsy API.
Aby wymusić, aby foreach
rozważał tylko asynchroniczne interfejsy API, await
jest wstawiany w następujący sposób:
await foreach (var i in enumerable)
Nie zostanie podana żadna składnia, która będzie obsługiwać użycie asynchronicznego lub synchronicznego interfejsu API; deweloper musi dokonać wyboru na podstawie używanej składni.
Semantyka
Przetwarzanie w czasie kompilacji instrukcji await foreach
najpierw określa typ kolekcji , typ wyliczania i typ iteracji wyrażenia (bardzo podobny do https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/statements.md#1395-the-foreach-statement). Determinacja przebiega w następujący sposób:
- Jeśli typ
X
wyrażenia jestdynamic
lub jest typem tablicy, zostanie wygenerowany błąd i nie zostaną wykonane żadne dalsze kroki. - W przeciwnym razie określ, czy typ
X
ma odpowiednią metodęGetAsyncEnumerator
:- Przeprowadź wyszukiwanie składowych na typie
X
z identyfikatoremGetAsyncEnumerator
i bez argumentów typu. Jeśli wyszukiwanie członka nie generuje dopasowania, generuje niejednoznaczność lub generuje dopasowanie, które nie należy do grupy metod, sprawdź interfejs wyliczalny zgodnie z opisem poniżej. - Przeprowadź rozpoznawanie przeciążeń przy użyciu wynikowej grupy metod i pustej listy argumentów. Jeśli rozpoznawanie przeciążenia nie powoduje zastosowania metod, powoduje niejednoznaczność lub powoduje utworzenie jednej najlepszej metody, ale ta metoda jest statyczna lub nie jest publiczna, sprawdź interfejs wyliczalny, jak opisano poniżej.
- Jeśli zwracany typ
E
metodyGetAsyncEnumerator
nie jest klasą, strukturą ani typem interfejsu, zostanie wygenerowany błąd i nie zostaną wykonane żadne dalsze kroki. - Wyszukiwanie elementu jest wykonywane na
E
z identyfikatoremCurrent
i bez argumentów typu. Jeśli wyszukiwanie elementu członkowskiego nie daje dopasowania, wynik jest błędem lub wynikiem jest wszystko, z wyjątkiem właściwości wystąpienia publicznego, która zezwala na odczyt, zostanie wygenerowany błąd i nie zostaną podjęte żadne dalsze kroki. - Wyszukiwanie elementu jest wykonywane na
E
z identyfikatoremMoveNextAsync
i bez argumentów typu. Jeśli wyszukiwanie składowe nie daje dopasowania, wynik jest błędem lub wynikiem jest coś poza grupą metod, generowany jest błąd i nie są wykonywane żadne dalsze kroki. - Rozpoznawanie przeciążenia jest wykonywane w grupie metod z pustą listą argumentów. Jeśli rozstrzyganie przeciążenia nie prowadzi do żadnych możliwych metod, powoduje niejednoznaczność lub prowadzi do jednej najlepszej metody, ale ta metoda jest statyczna, niepubliczna lub jej typ zwracany nie można przekazać do
bool
, zostanie wygenerowany błąd i nie zostaną podjęte żadne dalsze kroki. - Typ kolekcji to
X
, typ modułu wyliczającego jestE
, a typ iteracji jest typem właściwościCurrent
.
- Przeprowadź wyszukiwanie składowych na typie
- W przeciwnym razie sprawdź interfejs enumerowalny:
- Jeśli wśród wszystkich typów
Tᵢ
, dla których istnieje niejawna konwersja zX
naIAsyncEnumerable<ᵢ>
, istnieje unikatowy typT
taki, żeT
nie jest dynamiczny i dla wszystkich innychTᵢ
istnieje niejawna konwersja zIAsyncEnumerable<T>
doIAsyncEnumerable<Tᵢ>
, typ kolekcji jest interfejsemIAsyncEnumerable<T>
, typ wyliczający jest interfejsemIAsyncEnumerator<T>
, a typ iteracji toT
. - W przeciwnym razie, jeśli istnieje więcej niż jeden taki typ
T
, zostanie wygenerowany błąd i nie zostaną podjęte żadne dalsze kroki.
- Jeśli wśród wszystkich typów
- W przeciwnym razie zostanie wygenerowany błąd i nie zostaną podjęte żadne dalsze kroki.
Powyższe kroki, jeśli powiedzie się, jednoznacznie tworzą typ kolekcji C
, typ modułu wyliczającego E
i typ iteracji T
.
await foreach (V v in x) «embedded_statement»
następnie jest rozszerzany na:
{
E e = ((C)(x)).GetAsyncEnumerator();
try {
while (await e.MoveNextAsync()) {
V v = (V)(T)e.Current;
«embedded_statement»
}
}
finally {
... // Dispose e
}
}
Treść bloku finally
jest skonstruowana zgodnie z następującymi krokami:
- Jeśli typ
E
ma odpowiednią metodęDisposeAsync
:- Przeprowadź wyszukiwanie składowych na typie
E
z identyfikatoremDisposeAsync
i bez argumentów typu. Jeśli wyszukiwanie członka nie generuje dopasowania, generuje niejednoznaczność lub tworzy dopasowanie, które nie jest grupą metod, sprawdź interfejs usuwania zasobów zgodnie z poniższym opisem. - Przeprowadź rozpoznawanie przeciążeń przy użyciu wynikowej grupy metod i pustej listy argumentów. Jeśli rozpoznawanie przeciążenia nie powoduje zastosowania metod, powoduje niejednoznaczność lub powoduje utworzenie jednej najlepszej metody, ale ta metoda jest statyczna lub nie jest publiczna, sprawdź interfejs usuwania, jak opisano poniżej.
- Jeśli zwracany typ metody
DisposeAsync
nie jest oczekiwany, zostanie wygenerowany błąd i nie zostaną podjęte żadne dalsze kroki. - Klauzula
finally
jest rozszerzana na semantyczny odpowiednik:
finally { await e.DisposeAsync(); }
- Przeprowadź wyszukiwanie składowych na typie
- W przeciwnym razie, jeśli istnieje niejawna konwersja z
E
do interfejsuSystem.IAsyncDisposable
, wówczas- Jeśli
E
jest typem wartości niemającym wartości null, klauzulafinally
rozszerza się do semantycznego odpowiednika:
finally { await ((System.IAsyncDisposable)e).DisposeAsync(); }
- W przeciwnym razie klauzula
finally
jest rozszerzana na semantyczny odpowiednik:
z wyjątkiem tego, że jeślifinally { System.IAsyncDisposable d = e as System.IAsyncDisposable; if (d != null) await d.DisposeAsync(); }
E
jest typem wartościowym lub parametrem typu utworzonego jako typ wartościowy, konwersjae
naSystem.IAsyncDisposable
nie powoduje pakowania.
- Jeśli
- W przeciwnym razie klauzula
finally
jest rozszerzana do pustego bloku:finally { }
ConfigureAwait
Ta kompilacja oparta na wzorcu umożliwi użycie ConfigureAwait
dla wszystkich await za pośrednictwem metody rozszerzenia ConfigureAwait
.
await foreach (T item in enumerable.ConfigureAwait(false))
{
...
}
Będzie to również oparte na typach, które dodamy do platformy .NET, prawdopodobnie 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);
}
}
}
}
Należy pamiętać, że to podejście nie umożliwi użycia ConfigureAwait
z wyliczeniami opartymi na wzorcu, ale już jest tak, że ConfigureAwait
jest ujawniane tylko jako rozszerzenie dla Task
/Task<T>
/ValueTask
/ValueTask<T>
i nie można go zastosować do dowolnych oczekujących elementów, ponieważ ma sens tylko wtedy, gdy jest stosowane do Tasków (kontroluje zachowanie zaimplementowane w obsłudze kontynuacji Tasków) i w związku z tym nie ma sensu w przypadku używania wzorca, w którym oczekujące elementy mogą nie być Taskami. Każdy, kto zwraca oczekujące rzeczy, może zapewnić własne niestandardowe zachowanie w takich zaawansowanych scenariuszach.
(Jeśli możemy wymyślić sposób obsługi rozwiązania ConfigureAwait
na poziomie zakresu lub zestawu, nie będzie to konieczne).
Iteratory asynchroniczne
Język / kompilator będą obsługiwać tworzenie elementów IAsyncEnumerable<T>
i IAsyncEnumerator<T>
, a także ich używanie. Obecnie język obsługuje pisanie iteratora, na przykład:
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");
}
}
lecz await
nie można używać wewnątrz tych iteratorów. Dodamy obsługę.
Składnia
Istniejąca obsługa języka dla iteratorów rozpoznaje iteracyjny charakter metody na podstawie tego, czy zawiera ona jakiekolwiek yield
. To samo będzie dotyczyć iteratorów asynchronicznych. Takie iteratory asynchroniczne będą oznaczane i rozróżniane od iteratorów synchronicznych poprzez dodanie async
do ich podpisu, a następnie muszą także mieć IAsyncEnumerable<T>
lub IAsyncEnumerator<T>
jako ich typ zwracany. Na przykład powyższy przykład można napisać jako iterator asynchroniczny w następujący sposób:
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");
}
}
Rozważane alternatywy:
-
Nie używanie
async
w sygnaturze: Użycieasync
jest prawdopodobnie technicznie wymagane przez kompilator, ponieważ wykorzystuje go do określenia, czyawait
jest prawidłowy w tym kontekście. Ale nawet jeśli nie jest to wymagane, ustaliliśmy, żeawait
mogą być używane tylko w metodach oznaczonych jakoasync
i wydaje się ważne, aby zachować spójność. -
Włączanie konstruktorów niestandardowych dla
IAsyncEnumerable<T>
: To jest coś, czemu moglibyśmy się przyjrzeć w przyszłości, ale maszyneria jest skomplikowana i nie obsługujemy tego dla naszych odpowiedników synchronicznych. -
posiadanie słowa kluczowego
iterator
w sygnaturze: asynchroniczne iteratory będą używaćasync iterator
w sygnaturze, ayield
może być używane tylko w metodachasync
, które zawierałyiterator
;iterator
następnie będzie opcjonalne dla synchronicznych iteratorów. W zależności od perspektywy, ma to tę zaletę, że dzięki podpisowi metody można jednoznacznie stwierdzić, czyyield
jest dozwolone oraz czy metoda faktycznie ma zwracać instancje typuIAsyncEnumerable<T>
, w przeciwieństwie do tworzenia ich przez kompilator w oparciu o to, czy kod używayield
, czy nie. Ale różni się to od iteratorów synchronicznych, które nie wymagają tego i nie można ich zmusić, żeby tego wymagały. Ponadto niektórzy deweloperzy nie lubią dodatkowej składni. Gdybyśmy projektowali go od podstaw, prawdopodobnie uznalibyśmy to za wymagane, ale w tym momencie jest o wiele więcej wartości w utrzymywaniu iteratorów asynchronicznych blisko iteratorów synchronicznych.
LINQ
Istnieje ponad 200 przeciążeń metod dla klasy System.Linq.Enumerable
, z których wszystkie działają pod względem IEnumerable<T>
; niektóre z nich akceptują IEnumerable<T>
, niektóre z nich generują IEnumerable<T>
, a wiele robi jedno i drugie. Dodanie obsługi LINQ dla IAsyncEnumerable<T>
prawdopodobnie wiązałoby się z duplikowaniem wszystkich tych przeciążeń dla IAsyncEnumerable<T>
, dla około 200. A ponieważ IAsyncEnumerator<T>
prawdopodobnie częściej występuje jako samodzielna jednostka w świecie asynchronicznym niż IEnumerator<T>
w świecie synchronicznym, możemy potrzebować około 200 dodatkowych przeciążeń współpracujących z IAsyncEnumerator<T>
. Ponadto duża liczba przeciążeń dotyczy predykatów (np. Where
z parametrem Func<T, bool>
), i może być pożądane posiadanie przeciążeń opartych na IAsyncEnumerable<T>
, które obsługują zarówno synchroniczne, jak i asynchroniczne predykaty (np. Func<T, ValueTask<bool>>
w dodatku do Func<T, bool>
). Chociaż nie ma to zastosowania do wszystkich obecnie ok. 400 nowych przeciążeń, przybliżone obliczenie polega na tym, że byłoby stosowane do połowy, co oznacza kolejne ok. 200 przeciążeń, w sumie ok. 600 nowych metod.
Jest to zdumiewająca liczba interfejsów API, z możliwością jeszcze większej liczby, gdy rozważane są biblioteki rozszerzeń, takie jak Rozszerzenia interakcyjne (Ix). Ale Ix ma już implementację wielu z nich i wydaje się, że nie ma wielkiego powodu, aby zduplikować to działanie; Zamiast tego powinniśmy pomóc społeczności poprawić Ix i zalecić go, gdy deweloperzy chcą używać LINQ z IAsyncEnumerable<T>
.
Istnieje również problem ze składnią zrozumienia zapytania. Oparty na wzorcu charakter zrozumienia zapytań umożliwiłby im "po prostu pracę" z niektórymi operatorami, np. jeśli Ix udostępnia następujące metody:
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);
następnie ten kod w języku C# będzie "po prostu działał":
IAsyncEnumerable<int> enumerable = ...;
IAsyncEnumerable<int> result = from item in enumerable
where item % 2 == 0
select item * 2;
Nie ma jednak składni zrozumienia zapytań, która obsługuje używanie await
w klauzulach, więc na przykład, gdyby Ix zostało dodane:
public static IAsyncEnumerable<TResult> Select<TSource, TResult>(this IAsyncEnumerable<TSource> source, Func<TSource, ValueTask<TResult>> func);
wtedy będzie to "po prostu działać":
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;
}
ale nie byłoby sposobu na napisanie go za pomocą await
wbudowanej w klauzuli select
. W ramach oddzielnego wysiłku możemy przyjrzeć się dodaniu async { ... }
wyrażeń do języka. W tym momencie możemy zezwolić na ich używanie w zrozumieniach zapytań, a powyższe zamiast tego można napisać jako:
IAsyncEnumerable<int> result = from item in enumerable
where item % 2 == 0
select async
{
await Task.Yield();
return item * 2;
};
lub umożliwienie użycia await
bezpośrednio w wyrażeniach, na przykład poprzez obsługę async from
. Jednak jest mało prawdopodobne, aby projekt tutaj miał wpływ na resztę zestawu funkcji w jeden lub drugi sposób, i nie jest to szczególnie opłacalne do inwestowania w tej chwili, więc propozycja jest, aby obecnie nie robić tutaj nic dodatkowego.
Integracja z innymi platformami asynchronicznymi
Integracja z IObservable<T>
i innymi platformami asynchronicznymi (np. strumieniami reaktywnymi) byłaby wykonywana na poziomie biblioteki, a nie na poziomie języka. Na przykład wszystkie dane z IAsyncEnumerator<T>
można publikować w IObserver<T>
po prostu przez await foreach
"przez moduł wyliczający i OnNext
"ing data to the observer, więc możliwe jest AsObservable<T>
extension method( Metoda rozszerzenia AsObservable<T>
). Korzystanie z IObservable<T>
w await foreach
wymaga buforowania danych (w przypadku dodania innego elementu, gdy poprzedni element jest nadal przetwarzany), ale taki adapter przesuwno-pobierający można łatwo zaimplementować, aby umożliwić pobieranie IObservable<T>
z IAsyncEnumerator<T>
. Itp. Rx/Ix udostępnia już prototypy takich implementacji, a biblioteki takie jak https://github.com/dotnet/corefx/tree/master/src/System.Threading.Channels zapewniają różne rodzaje buforowania struktur danych. Język nie musi być zaangażowany na tym etapie.
C# feature specifications