IAsyncEnumerable - to jest dobre!

2020-12-14

C# 8 wprowadził pewną nowinkę - IAsyncEnumerable. Co prawda na horyzoncie jest już C# 9, ale jakoś ta nowinka z poprzedniej wersji jakoś nie trafiła pod strzechy - a może być w pewnych sytuacjach bardzo przydatna.

W największym skrócie IAsyncEnumerable umożliwia korzystanie z yield return w metodach oznaczonych słowem kluczowym async - wcześniej nie było to możliwe. Ktoś powie: “zaraz, zaraz - przecież dotychczas mogłem napisać sygnaturę metody w stylu async Task<IEnumerable<SomeType>>, co nie?“. To prawda - ale taka sygnatura oznaczała, że asynchronicznie zwracamy enumerację, jednak już samo enumerowanie asynchroniczne nie jest. Tymczasem IAsyncEnumerable to coś więcej - to asynchroniczne enumerowanie, to znaczy: każdy “krok” enumeracji jest asynchroniczny. Co to oznacza? Na poniższym przykładzie można zobaczyć różnicę. Mamy dwa podejścia do wybrania pierwszego elementu z “asynchronicznej enumeracji” - z Task<IEnumerable<int>> oraz IAsyncEnumerable<int>. Jaki jest wynik na konsoli?


public static class TestRun
{
    static async Task Run()
    {
        var test = new Test();
        Console.WriteLine("Task<IEnumerable<int>>:");
        (await test.DoWithIEnumerable()).FirstOrDefault();

        Console.WriteLine("\nIAsyncEnumerable<int>:");
        await test.DoWithIAsyncEnumerable().FirstOrDefaultAsync();
    }

    public class Test
    {
        private int[] numbers = new[] { 2, 4, 8, 16, 32 };
        public async Task<IEnumerable<int>> DoWithIEnumerable()
        {
            var result = new List<int>();
            foreach(var number in numbers)
            {
                var item = await ReturnWithDelay(number);
                result.Add(item);
            }
            return result;
        }
        public async IAsyncEnumerable<int> DoWithIAsyncEnumerable()
        {
            foreach (var number in numbers)
            {
                var item = await ReturnWithDelay(number);
                yield return item;
            }
        }
        private Task<int> ReturnWithDelay(int x)
        {
            return Task.Run(() =>
            {
                Console.WriteLine($"Waiting 2 seconds before returning... {x}");
                Task.Delay(2000);
                return x;
            });
        }
    }
}

Wyjście:

Task<IEnumerable<int>>:
Waiting 2 seconds before returning... 2
Waiting 2 seconds before returning... 4
Waiting 2 seconds before returning... 8
Waiting 2 seconds before returning... 16
Waiting 2 seconds before returning... 32

IAsyncEnumerable<int>:
Waiting 2 seconds before returning... 2

Widzicie różnicę? W przypadku Task<IEnumerable<int>> musimy de facto najpierw asynchronicznie zbudować całą naszą kolekcję, aby ją zwrócić klientowi. Co z tego, że klient chce tylko pierwszy element, my i tak musimy zbudować całość. Tymczasem w IAsyncEnumerable enumerujemy tak, jak w “klasycznym” IEnumerable: jeśli caller chce tylko jeden element, to “napracujemy” się tylko nad wybraniem tego jednego elementu.

Przykład jest dosyć sztuczny, nasuwa się zatem pytanie, kiedy takie coś możne nam się przydać. Otóż, dobrym przykładem (sam ostatnio używałem w takiej sytuacji IAsyncEnumerable) jest wyciąganie listy dokumentów z Cosmos DB: ze względu na charakterystykę komunikacji z Cosmosem, de facto takiej listy nie dostaniemy jednym zapytaniem do bazy. Cosmos niejako “paginuje” (“porcjuje”) wyniki - czasem potrzeba kilku strzałów do Cosmosa, aby wybrać wyszystkie dokumenty z listy. A każdy taki strzał jest asynchroniczny. Widzimy więc od razu, że w przypadku, gdy caller potrzebuje tylko podzbioru wyników (w skrajnym przypadku - chce woła tylko FirstOrDefault), nie musimy robić wszystkich zapytań do Cosmosa, by wpierw zbudować całą listę wynikową.

Zatem wszędzie tam, gdzie budowanie enumeracji jest asynchroniczne - możemy zyskać stosując IAsyncEnumerable. Dzięki temu unikamy “ukrytego kosztu”: gdy metoda przedstawia się jako Task<IEnumerable<T>>, to nie wiemy (bez zaglądania do jej implementacji), co jest tak naprawdę asynchroniczne w budowaniu takiej enumeracji: czy każdy krok wymaga asynchronicznej operacji, czy może tylko jakiś krok wstępny/przygotowawczy. Natomiast przy IAsyncEnumerable jest to przejrzyste dla konsumenta naszej metody.

Na koniec - dwie sprawy, na które należy zwrócić uwagę:

  • po pierwsze - IAsyncEnumerable jest “awaitowalne”, czyli - jak w przykładzie powyżej - nie trzeba już go “opakowywać” w Task<...>,
  • jeśli chcemy coś wyciągnąć z IAsyncEnumerable za pomocą LINQ-owych metod, to musimy doinstalować NuGeta System.Linq.Async i korzystać z asyncowych odpowiedników - np. FirstOrDefaultAsync.

Robert Skarżycki - zdjęcie profilowe

Pisanina, której autorem jest Robert Skarżycki - programista .NET, mąż szczęśliwej żony, rodzic
moje bio
mój Twitter
mój LinkedIn
moje szkolenia i warsztaty

© 2022, Built with Gatsby & passion