Udostępnij za pośrednictwem


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.

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 akceptowanie CancellationToken: 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 same CancellationToken, które spowodowały anulowanie rzeczywistej pracy, zwykle byłyby tym samym tokenem przekazywanym do DisposeAsync, co czyni DisposeAsync bezwartościowe, ponieważ anulowanie pracy spowodowałoby, że DisposeAsync być no-op. Jeśli ktoś chce uniknąć zablokowania podczas oczekiwania na dyspozycję, może unikać czekania na wynikowy ValueTasklub czekać na niego tylko przez pewien czas.
  • DisposeAsync zwracanie Task: teraz, gdy istnieje niegeneryczny ValueTask i można go skonstruować z IValueTaskSource, zwracając ValueTask z DisposeAsync umożliwia ponowne użycie istniejącego obiektu jako obietnicy reprezentującej ostateczne asynchroniczne ukończenie DisposeAsync, zapisywanie alokacji Task w przypadku, gdy DisposeAsync kończy się asynchronicznie.
  • Konfigurowanie DisposeAsync za pomocą bool continueOnCapturedContext (ConfigureAwait): Chociaż mogą występować problemy związane z tym, jak taka koncepcja jest przedstawiona using, foreachi innym konstrukcjom językowym, które z tego korzystają, w perspektywie interfejsu nie wykonuje żadnego awaiti nie ma nic do skonfigurowania... użytkownicy ValueTask mogą z niego korzystać według własnego uznania.
  • IAsyncDisposable dziedziczy IDisposable: ponieważ należy korzystać tylko z jednego lub drugiego, nie ma sensu zmuszać do implementacji obu typów.
  • IDisposableAsync zamiast IAsyncDisposable: 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życie Task<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ąc ValueTask<bool>, umożliwiamy obiektowi wyliczającemu samodzielne implementowanie IValueTaskSource<bool> i użycie jako zaplecza dla ValueTask<bool> zwracanego z MoveNextAsync, co z kolei pozwala na znaczne zmniejszenie kosztów ogólnych.
  • ValueTask<(bool, T)> MoveNextAsync();: Nie tylko trudniej jest przyswoić, ale oznacza to, że T 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);: wynik out 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> nieimplementowanie IAsyncDisposable: 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 jako public 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 i Current 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 dla WaitForNextAsync/TryGetNext większość iteracji kończy się synchronicznie, umożliwiając ciasną pętlę wewnętrzną z TryGetNext, 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ów out. 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:

  1. IAsyncEnumerable<T> / IAsyncEnumerator<T> są niewrażliwe na anulowanie: CancellationToken nie pojawia się nigdzie. Anulowanie jest osiągane przez logiczne włączenie CancellationToken do wyliczalnego i/lub enumeratora w sposób, który jest odpowiedni, np. podczas wywoływania iteratora, przekazywanie CancellationToken jako argument do metody iteratora i używanie go w treści iteratora, tak jak każdy inny parametr.
  2. IAsyncEnumerator<T>.GetAsyncEnumerator(CancellationToken): przekazujesz CancellationToken do GetAsyncEnumerator, a kolejne operacje MoveNextAsync przestrzegają go, tak jak to możliwe.
  3. IAsyncEnumerator<T>.MoveNextAsync(CancellationToken): przekazujesz CancellationToken do poszczególnych wywołań MoveNextAsync.
  4. 1 && 2: Oboje osadzacie CancellationTokenw wyliczalnym/wyliczaczu i przekazujecie CancellationTokendo GetAsyncEnumerator.
  5. 1 && 3: Obojga osadzacie CancellationTokenw swojej wyliczalnej/iteratorze i przekazujecie CancellationTokendo MoveNextAsync.

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 do GetAsyncEnumerator trafia do ciała iteratora? Możemy uwidocznić nowe słowo kluczowe iterator, które można wyłączyć, aby uzyskać dostęp do CancellationToken przekazane do GetEnumerator, 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ąc GetAsyncEnumerator na nim, w tym przypadku może po prostu przekazać CancellationToken jako argument do metody .
  • W jaki sposób CancellationToken przekazane do MoveNextAsync dostać się do treści metody? Jest to jeszcze bardziej problematyczne, ponieważ jeśli jest ujawniony z lokalnego obiektu iterator, 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łaniu MoveNextAsync, 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 przekazanie CancellationToken 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 z IAsyncEnumerable<T>, która będzie przechowywać podany token, a następnie przekaże go do GetAsyncEnumerator zawiniętego wyliczenia, gdy zostanie wywołana GetAsyncEnumerator 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 do GetEnumerator lub MoveNextAsync 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 do GetAsyncEnumerator/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ą typ IAsyncEnumerable.

Istnieją dwa główne scenariusze zużycia:

  1. await foreach (var i in GetData(token)) ..., gdzie użytkownik wywołuje metodę async-iterator,
  2. await foreach (var i in givenIAsyncEnumerable.WithCancellation(token)) ..., w którym konsument zajmuje się danym wystąpieniem IAsyncEnumerable.

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:

  1. jeśli używasz GetData(token), token zostanie zapisany w wyliczenie asynchroniczne i będzie używany w iteracji,
  2. Jeśli używasz givenIAsyncEnumerable.WithCancellation(token), token przekazany do GetAsyncEnumerator 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 jest dynamic 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 identyfikatorem GetAsyncEnumerator 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 metody GetAsyncEnumerator 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 identyfikatorem Current 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 identyfikatorem MoveNextAsync 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 jest E, a typ iteracji jest typem właściwości Current.
  • W przeciwnym razie sprawdź interfejs enumerowalny:
    • Jeśli wśród wszystkich typów Tᵢ, dla których istnieje niejawna konwersja z X na IAsyncEnumerable<ᵢ>, istnieje unikatowy typ T taki, że T nie jest dynamiczny i dla wszystkich innych Tᵢ istnieje niejawna konwersja z IAsyncEnumerable<T> do IAsyncEnumerable<Tᵢ>, typ kolekcji jest interfejsem IAsyncEnumerable<T>, typ wyliczający jest interfejsem IAsyncEnumerator<T>, a typ iteracji to T.
    • 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.
  • 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 identyfikatorem DisposeAsync 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();
      }
    
  • W przeciwnym razie, jeśli istnieje niejawna konwersja z E do interfejsu System.IAsyncDisposable, wówczas
    • Jeśli E jest typem wartości niemającym wartości null, klauzula finally rozszerza się do semantycznego odpowiednika:
      finally {
          await ((System.IAsyncDisposable)e).DisposeAsync();
      }
    
    • W przeciwnym razie klauzula finally jest rozszerzana na semantyczny odpowiednik:
      finally {
          System.IAsyncDisposable d = e as System.IAsyncDisposable;
          if (d != null) await d.DisposeAsync();
      }
      
      z wyjątkiem tego, że jeśli E jest typem wartościowym lub parametrem typu utworzonego jako typ wartościowy, konwersja e na System.IAsyncDisposable nie powoduje pakowania.
  • 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życie async jest prawdopodobnie technicznie wymagane przez kompilator, ponieważ wykorzystuje go do określenia, czy await jest prawidłowy w tym kontekście. Ale nawet jeśli nie jest to wymagane, ustaliliśmy, że await mogą być używane tylko w metodach oznaczonych jako asynci 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, a yield może być używane tylko w metodach async, które zawierały iterator; 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ć, czy yield jest dozwolone oraz czy metoda faktycznie ma zwracać instancje typu IAsyncEnumerable<T>, w przeciwieństwie do tworzenia ich przez kompilator w oparciu o to, czy kod używa yield, 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.