Czy async/await w „jednolinijkowcach” ma sens?

Podczas nagrywania czwartego odcinka „Distributed .NET Core” (do którego oglądania serdecznie Cię zapraszam) wspólnie z Piotrkiem poruszyliśmy przez moment kwestię zasadności użycia async/await w tzw. „one line-rach” czyli metodach, których ciało posiada jedynie jedną linie i najczęściej jest implementowane za pomocą operatora „goes to” (nie mylić z lambdami). Przykład takiej metody:

 


        public async Task<string> GetTextAsync()
            => await Task.FromResult("Hello world!");

 

Pytanie jest z pozoru proste. Czy użycie async/await w tym przypadku jest poprawne i jednocześnie zasadne? Pierwsza część nie wymaga głębszej analizy. Powyższy zapis z puntu widzenia syntaktyki języka jest jak najbardziej poprawny. Jeżeli masz już doświadczenie z programowaniem asynchronicznym w C# to zapewne nie jest to dla Ciebie zaskoczeniem. Ten kod się kompiluje i działa tak ja można by się tego spodziewać. Co więcej, rzekłbym że w większość asynchronicznych „jednolinijkowców”, które spotykam w kodzie C# jest zapisana właśnie w takiej postaci.

Co jednak z zasadnością przedstawionego kodu? Czy nie jest on aby zbyt nadmiarowy? Dlaczego nie zamienić go na wersję następującą?

 


        public Task<string> GetTextAsync()
            => Task.FromResult("Hello world!");

 

Tu znów zapis z punktu widzenia syntaktyki jest poprawny. Pominięte zostało jednak słowo kluczowe async, a co za tym idzie operator await. Ten kod również zadziała tak jakbyśmy się spodziewali. Można by więc uznać, że dobór „stylu” leży indywidualnie po stronie programisty, jeśli nie ma on żadnego znaczenia. Z punktu widzenia zachowania oprogramowania: tak. Z punktu widzenia wydajności i semantyki tego wyrażenia: no nie do końca.

 

Kilka faktów o async/await w C#

Zanim przejdziemy do analizy tego co napisałem warto przypomnieć sobie kilka faktów dotyczących async/await w C#:

  • słówko kluczowe async w sygnaturze metody nie determinuje jej asynchroniczności. Dopiero pojawienie się operatora await daje na to dużą (lecz nie 100%) szansę. Pisałem o tym tutaj.
  • Task to obiekt, który reprezentuje zadanie asynchroniczne, ale sam z siebie nie czyni metody asynchroniczną.
  • operator unarny await stosujemy, aby zaznaczyć że do dalszego przetwarzania metody potrzebny jest nam rezultat zadania asynchronicznego. Mówiąc inaczej do dalszego działa potrzebujemy dokonać ewaluacji rezultatu Task. Jeżeli zadanie się zakończyło i jest to możliwe (happy path) nie musimy zawieszać działanie metody i działa ona w 100% synchronicznie. Jeżeli na rezultat musimy oczekiwać, wtedy działanie metody zostaje zawieszone i sterowanie zostaje zwrócone do podmiotu „wyżej” (propagujemy asynchroniczność).
  • Po zakończeniu operacji asynchronicznej sterowanie zostaje przywrócone do ostatniego miejsca zawieszenia metody. Jest to możliwe, ponieważ operator await w kodzie binarnym (IL) jest kompilowany do maszyny stanów, która zarządza całym „flow” procesowania metody asynchronicznej. Pisałem o tym tutaj, a jeżeli wolisz kontent polski to mówiłem o tym temacie szerzej na 4Developers.

 

Po tym krótkim przypomnieniu, warto byłoby wprowadzić jeszcze bardziej życiowy przykład kodu, do którego będziemy mogli odnieść się w kolejnych częściach tego tekstu:

 

    class Program
    {
        static async Task Main(string[] args)
        {
            var service = new Service();
            var data = await service.GetDataAsync();

            Console.WriteLine(data);
        }
    }

    public interface IService
    {
        Task<string> GetDataAsync();
    }
    
    public class Service : IService
    {
        private readonly IRepository _repository;

        public Service()
            => _repository = new Repository();

        public async Task<string> GetDataAsync()
            => await _repository.GetDataFromDbAsync();
    }

    public interface IRepository
    {
        Task<string> GetDataFromDbAsync();
    }
    
    public class Repository : IRepository
    {
        public async Task<string> GetDataFromDbAsync()
        {
            await Task.Delay(3000);
            return "Result";
        }
    }

 

Semantyka wyrażenia

Rozpocznijmy od nieco bardziej „filozoficznej” części, a mianowicie czy zapis, który aktualnie znajduje się w klasie Service jest poprawny w odniesieniu do roli jaką ta klasa spełnia. Jak widać jest to warstwa pośrednicząca pomiędzy DAL (repozytorium), a podmiotem, które ów dane chce wykorzystać do wypisania w konsoli. W tym przypadku jest to metoda Main, ale oczywiście mógłby być to np. kontroler MVC w przypadku ASP.NET Core. Popatrzmy teraz na implementację GetDataAsync i w odniesieniu do tego co napisałem w poprzednim paragrafie, odpowiedzmy na pytanie co właściwie się tu dzieje? Metoda wywołuje asynchroniczną operację GetDataFromDbAsync, którą od razu await-uje. Oznacza to, że do jej ukończenia potrzebne jest uprzednie ukończenie zleconego zadania, aby móc pobrać z niego rezultat typu tekstowego. Następnie ów rezultat zostanie ponownie „zapakowany” w kolejny obiekt typu Task, który będzie mógł zostać await-owany w klasie Program. Pytanie brzmi, czy faktycznie serwis będący pośrednikiem musi ewaluować rezultat Task? Czy jest to coś niezbędnego do przekazania informacji „wyżej”? Jeżeli odpowiedź nie jest dla Ciebie oczywista to pozwól, ze posłużę się uproszczoną, acz dość obrazową analogią.

Wyobraź sobie, że idziesz do McDonald’s ze swoim znajomym. Kolega prosi Ciebie abyś zamówił mu jedzenie. Podchodzisz do kasy bądź kiosku interaktywnego, kompletujesz zamówienie, płacisz i otrzymujesz numerek ze swoim zamówieniem, który należy okazać kiedy zostanie on wywołany przez pracownika. W tym miejscu zróbmy małe stop i przyrównajmy to do naszego kodu:

  • złożone zamówienie – Wywołanie asynchronicznej metody, która trochę potrwa
  • numerek zamówienia – Task, czyli obiekt, który reprezentuje zleconą pracę asynchroniczną i który jest niezbędny do pobrania rezultatu.
  • jedzenie – rezultat wykonania asynchronicznej metody
  • Ty – warstwa pośrednicząca między metodą asynchroniczną, a swoim znajomym
  • Twój znajomy – finalny konsument zadania asynchronicznego (Main w kodzie)

W jaki sposób Twój znajomy może otrzymać swoje jedzenie? Na początku zróbmy to tak jak napisane zostało w kodzie. Wygląda to następująco:

  1. Złożyłeś zamówienie i czekasz
  2. Numerek zostaje wywołany
  3. Podchodzisz do pracownika i odbierasz zamówienie
  4. Wołasz znajomego, żeby do Ciebie podszedł
  5. Kolega odbiera od Ciebie zamówienie
  6. Kolega je

Brzmi bez sensu? Takie podejście (nie do końca idealnie odwzorowane) nie wydaje się naturalne i widać tu trochę nadmiarowości. Zmodyfikujmy je zatem:

  1. Złożyłeś zamówienie
  2. Przekazujesz otrzymany numerek z ręki do ręki (synchronicznie) swojemu znajomemu, który czeka
  3. Numerek zostaje wywołany
  4. Kolega odbiera zamówienie
  5. Kolega je

Teraz ma to więcej sensu! Ogólnie ciekawym jest, że pewne konstrukcje w kodzie przestają mieć sens kiedy zaczniemy przekładać je na rzeczywistość. Trzeba przyznać, że po samym kodzie nie było tego widać na pierwszy rzut oka.

Wracając do kodu, widzimy że pozbycie się async/await z klasy Service nie zaburzy ogólnego „flow”, a zmieni się jedynie funkcja metody GetDataAsync. Zamiast pobierać rezultat z Task ,który nie jest do niczego potrzebny, przekażemy go synchronicznie do podmiotu, który faktycznie go użyje.

 

Wydajność

Teraz zagadnienie bardziej techniczne, którego nie przedstawiłem w mojej analogi z jedzeniem. Wydajność. Jak to się ma do async/await? Jak pisałem w części o faktach, operator await jest niczym innym jak lukrem składniowym w C#. Na poziomie IL nie istnieje, a jest on tłumaczony na implementację maszyny stanów, która zarządza i czuwa nad tym, aby kod mógł zawieszać swoje działanie i wracać do odpowiednich jego fragmentów. Zobaczmy jak taka maszyna wygląda dla naszego przykładu:

 


public class Service : IService
{
    [StructLayout(LayoutKind.Auto)]
    [CompilerGenerated]
    private struct <GetDataAsync>d__2 : IAsyncStateMachine
    {
        public int <>1__state;

        public AsyncTaskMethodBuilder<string> <>t__builder;

        public Service <>4__this;

        private TaskAwaiter<string> <>u__1;

        private void MoveNext()
        {
            int num = <>1__state;
            Service service = <>4__this;
            string result;
            try
            {
                TaskAwaiter<string> awaiter;
                if (num != 0)
                {
                    awaiter = service._repository.GetDataFromDbAsync().GetAwaiter();
                    if (!awaiter.IsCompleted)
                    {
                        num = (<>1__state = 0);
                        <>u__1 = awaiter;
                        <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                        return;
                    }
                }
                else
                {
                    awaiter = <>u__1;
                    <>u__1 = default(TaskAwaiter<string>);
                    num = (<>1__state = -1);
                }
                result = awaiter.GetResult();
            }
            catch (Exception exception)
            {
                <>1__state = -2;
                <>t__builder.SetException(exception);
                return;
            }
            <>1__state = -2;
            <>t__builder.SetResult(result);
        }

        void IAsyncStateMachine.MoveNext()
        {
            //ILSpy generated this explicit interface implementation from .override directive in MoveNext
            this.MoveNext();
        }

        [DebuggerHidden]
        private void SetStateMachine(IAsyncStateMachine stateMachine)
        {
            <>t__builder.SetStateMachine(stateMachine);
        }

        void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
        {
            //ILSpy generated this explicit interface implementation from .override directive in SetStateMachine
            this.SetStateMachine(stateMachine);
        }
    }

    private readonly IRepository _repository;

    public Service()
    {
        _repository = new Repository();
    }

    [AsyncStateMachine(typeof(<GetDataAsync>d__2))]
    public Task<string> GetDataAsync()
    {
        <GetDataAsync>d__2 stateMachine = default(<GetDataAsync>d__2);
        stateMachine.<>4__this = this;
        stateMachine.<>t__builder = AsyncTaskMethodBuilder<string>.Create();
        stateMachine.<>1__state = -1;
        AsyncTaskMethodBuilder<string> <>t__builder = stateMachine.<>t__builder;
        <>t__builder.Start(ref stateMachine);
        return stateMachine.<>t__builder.Task;
    }
}

 

Jak widzisz jest to całkiem pokaźny kod, który po analizie nie jest tak straszny jak wygląda na pierwszy rzut oka (ponownie zachęcam do obejrzenia prezentacji z 4Developers). Zobaczmy jak wygląda wygenerowany kod jeśli pozbędziemy się async/await:

 


    public Task<string> GetDataAsync()
    {
        return _repository.GetDataFromDbAsync();
    }

 

Jak wspomniałem, jeżeli metoda nie posiada operatora await to jej wykonanie jest w pełni synchroniczne i nie wymaga dodatkowych mechanizmów zarządzających jej przetwarzaniem. W związku z powyższym maszyna stanów jak i dodatkowe alokacje (EDIT: tylko w Debug, w Release wszystko skorelowane z maszyną stanów jest strukturą i boxowane w ostateczności) innych obiektów (np. methodBuildera) nie są konieczne. Mając to na uwadze, nie trudno przypuszczać, że wersja druga powinna działać szybciej. Aby jednak nie być gołosłownym przeprowadziłem mały test korzystając z BenchmarkDotNet:

 


    class Program
    {
        static async Task Main(string[] args)
        {
            var runner = BenchmarkRunner.Run<Service>();
        }
    }

    public interface IService
    {
        Task<string> GetDataAsync();
    }
    
    public class Service : IService
    {
        private readonly IRepository _repository;

        public Service()
            => _repository = new Repository();

        [Benchmark]
        public async Task<string> GetDataAsync()
            => await _repository.GetDataFromDbAsync();
    }

    public interface IRepository
    {
        Task<string> GetDataFromDbAsync();
    }
    
    public class Repository : IRepository
    {
        public Task<string> GetDataFromDbAsync()
            => Task.FromResult("Result");
    }

 

Zwróć uwagę na kilka zmian. Po pierwsze metoda GetDataFromDbAsync nie jest już opóźniona o 3 sekundy, ponieważ wpływałoby to na wyniki testu. Co więcej ta metoda jest zaimplementowana bez async/await, tak aby sprawdzić czas działania tylko na jednym poziomie. Wyniki prezentują się następująco (oczywiście dla Release):

 

 

38 ns to oczywiście niesamowicie szybko! Zobaczmy jednak jak przedstawią się wyniki po usunięciu async/await z serwisu:

 

 

Ponad 4 razy szybciej! Zwróć jednak uwagę, że wynik z async/await dotyczył jednego poziomu, a niejednokrotnie takie jednolinijkowce pojawiają się w kilku warstwach. W związku z powyższym zobaczmy jak prezentować się będzie rezultat dla 2 warstw z async/await tj. serwis i repozytorium:

 


    public class Service : IService
    {
        private readonly IRepository _repository;

        public Service()
            => _repository = new Repository();

        [Benchmark]
        public async Task<string> GetDataAsync()
            => await _repository.GetDataFromDbAsync();
    }

    public class Repository : IRepository
    {
        public async Task<string> GetDataFromDbAsync()
            => await Task.FromResult("Result");
    }

 

 

Oczywiście każda kolejna warstwa powiększa ten wynik 😉

 

Podsumowując, jeżeli przyjdzie Ci pisać asynchroniczne jednolinijkowce w C#, a rezultat operacji nie ma znaczenia w danym kontekście, pomiń async/await. Nic nie wniesie do Twojego kodu, a jedynie go spowolni. Oczywiście większość osób nie odczuje tego na własnej skórze, ale jeżeli taka zmiana nic nie kosztuje… to może lepiej chuchać na zimne.

You may also like...

  • Chciałem zwrócić uwagę na jedną rzecz. Piszesz o alokacjach i oszczędności. Zarówno sama maszyna stanów jak i AsyncTaskMethodBuilder są strukturami. Jako struktury są tworzone na stosie a dopiero w przypadku ścieżki asynchronicznej, kiedy maszyna stanów jest boxowana. Oczywiście stworzenie struktury też kosztuje, ale tworzona jest na stosie. Ze stwierdzenia o „alokacji” można by wywnioskować, że zawsze tworzone są na stercie.

    • aaaa racja, nie wiem dlaczego miałem w głowie wersje z Debuga czyli private sealed class. Zaraz zmieniam, dzięki!

  • Pawel Lukasik

    Ehh, namęczyłem się z rozszyfrowaniem tego one line-rach w tytule. Nie znam się to się wypowiem i wydaje mi się, że powinno chyba być one line-ach, ale w ogóle chyba ten myślnik tutaj nie na miejscu bo tego stosuje się go raczej do skrótowców.

    Fajny przykład z McDonalds. Podobała mi się analogia.

    • Może zmienie na „jednolinijkowce” bo faktycznie ciężko się to czyta 😀

  • Pingback: dotnetomaniak.pl()