200% asynchronicznej mocy w C# z .NET Core 2.1

10

Programowanie asynchroniczne na dobre zagościło na platformie .NET. Proces transformacji wszystkich bibliotek nie był najszybszy, ale większość liczących się graczy na rynku komponentów przygotowało już wersje asynchroniczne. Z przyrostkiem Async czy bez, metody zwracające Task albo Task stały się naszą codziennością, zwiększając przepustowość aplikacji i zmniejszając jałowy czas czekania na zwrócenie danych przez bazę (albo dowolne inne IO).

Zatem skoro cała asynchroniczność miała przynieść takie zyski, to czy da się wycisnąć coś więcej? Czym może pochwalić się .NETCore 2.1 i w jakich przypadkach może nam pomóc w pisaniu bardziej wydajnych aplikacji? Na te pytania odpowiem w poniższym artykule.

Na początku był async

Zacznijmy od przypomnienia najprostszej składni związanej z programowaniem asynchronicznym. Załóżmy, że chcesz napisać metodę wywołującą inną metodę asynchroniczną Task GetData () i dodającą do jej wyniku jeden. Następnie wynik dodawania ma zostać zwrócony jako rezultat.
Pierwszym krokiem będzie zadeklarowanie sygnatury metody:

public Task<int> GetDataAndAdd1(int id)
{
  // TODO
}

Aby użyć słowa kluczowego await, musimy oznaczyć metodę jako async.

public async Task<int> GetDataAndAdd1(int id)
{
  var result = await GetData(id);
}

Pozostaje dodanie jedynki i zwrócenie rezultatu. Całe opakowanie wyniku w Task wykona za nas kompilator, podobnie jak transformację do tzw. kontynuacji, czyli metody wykonywanej po tym, jak GetData zakończy swoje wywołanie.

public async Task<int> GetDataAndAdd1(int id)
{
  var result = await GetData(id);
  return result + 1;
}

Dodatkowo, jeżeli nasz kod nie dba o kontekst synchronizacji (to temat na osobny artykuł), możemy dodać wywołanie funkcji ConfigureAwait(false), otrzymując nasze ostateczne wywołanie:

public async Task<int> GetDataAndAdd1(int id)
{
  var result = await GetData(id).ConfigureAwait(false);
  return result + 1;
}

Nie taki Task lekki, jak go malują

Istnieją przypadki, w których metoda asynchroniczna może być za ciężka. Weźmy pod lupę następującą modyfikację, która używa jakiegoś mechanizmu cache’owania.

public async Task<int> GetDataAndAdd1(int id)
{
  if (cache.TryGet(id, out var v))
  {
    return v;
  }

  var result = await GetData(id).ConfigureAwait(false);
  cache.Put(id, result + 1);
  return result + 1;
}

Jeżeli nasz cache został użyty w dobrym miejscu (i poprawnie), metoda zazwyczaj powinna pobierać swoje dane z cache’a. Zauważ, że we fragmencie z cache’em nie ma żadnej asynchroniczności – brak tam awaita. Mimo to rezultat będzie jednak opakowywany w nowo wygenerowany obiekt Task. Tworzenie obiektu to alokacje, a usuwanie alokacji to jedna z pewniejszych ścieżek do przyśpieszenia aplikacji. Co zatem można zrobić?

Pierwszym krokiem może być podzielenie metody na ścieżkę faktycznie asynchroniczną oraz tę synchroniczną, bez awaita.

public Task<int> GetDataAndAdd1(int id)
{
  if (cache.TryGet(id, out var v))
  {
    return Task.FromResult(v);
  }

  return RealGetDataAndAdd1AndPutInCache(id);
}

async Task<int> RealGetDataAndAdd1AndPutInCache(int id)
{
  var result = await GetData(id).ConfigureAwait(false);
  cache.Put(id, result + 1);
  return result + 1;
}

Alokacja pozostała: nowy Task jest tworzony przy każdym odnalezieniu danych w cache’u. Oczywiście można cache’ować obiekty Tasków, ale nadal wymaga to alokacji. Czy więc możemy cokolwiek zrobić? Okazuje się, że tak. W przypadkach, gdy metoda posiada ścieżkę wywołania synchronicznego, możemy użyć nowego typu ValueTask, który jest strukturą, a zatem nie alokuje pamięci na stercie. Oczywiście przekazywanie wartości zamiast referencji może kosztować więcej, dlatego warto to zmierzyć. Spójrzmy na zmienioną metodę.

public ValueTask<int> GetDataAndAdd1(int id)
{
  if (cache.TryGet(id, out var v))
  {
    return new ValueTask<int>(v);
  }

  return new ValueTask<int>(RealGetDataAndAdd1AndPutInCache(id));
}

async Task<int> RealGetDataAndAdd1AndPutInCache(int id)
{
  var result = await GetData(id).ConfigureAwait(false);
  cache.Put(id, result + 1);
  return result + 1;
}

Zauważ, że ścieżka asynchroniczna się nie zmieniła, podczas gdy ścieżka synchroniczna – bez awaitów – będzie alokować mniej. Mamy więc szansę na zysk wydajności.

Asynchroniczny ValueTask

Skoro ValueTask może spowodować szybsze wykonanie kodu synchronicznego, na pewno zastanawiasz się, czy nie dałoby się obsłużyć tą strukturą także ścieżki asynchronicznej. Aż do .NET Core 2.1 nie było takiej możliwości – ValueTask można było skonstruować tylko na podstawie wartości, co robiliśmy powyżej, używając danych z cache’a, albo na podstawie obiektu Task, który jest alokowany na stercie. Na szczęście wraz z .NET Core 2.1 nadszedł nowy konstruktor ValueTask umożliwiający podanie czegoś, co wygląda jak Task i może być używane jak Task, ale Taskiem nie jest. To nic innego jak IValueTaskSource, przy którym dodano nowy konstruktor dla ValueTask:

public ValueTask(IValueTaskSource<T> source, short token)

Patrząc na powyższy kod, można się zastanowić: „OK, ale ktoś będzie musiał stworzyć IValueTaskSource i to za każdym razem. Gdzie jest więc zysk?”. To bardzo dobre pytanie. Odpowiada na nie drugi parametr nazwany tokenem.

Token to nic innego jak identyfikator danego użycia IValueTaskSource. Oznacza to, że implementując metodę z użyciem tego konstruktora dla ValueTask, przy każdej nowej operacji i zwróceniu nowej wartości powinniśmy odpowiednio modyfikować token tak, aby powtórzenie się jego wartości było mało prawdopodobne. Operacja używana przez nową implementację gniazd sieciowych Socket polega na dodawaniu +1 do ostatnio użytej i zapewnianiu, że „short” nie zostanie przepełniony.

Kiedy będzie można użyć ponownie tego samego obiektu IValueTaskSource? Gdy ktoś wykona na nim await.

Jak zapewnić, że nie zostanie użyty kilka razy? Przechowując w source ostatnio użyty token. Jeżeli obiekt zostanie użyty ponownie, jego aktualny token będzie się różnił od zwróconego wcześniej w ValueTask. Proste, efektywne i wydajne.

To właśnie mechanizm wielokrotnego używania tych samych IValueTaskSource pomógł w niezwykle efektywnej implementacji gniazd sieciowych w .NET Core 2.1. Ze względu na to, że na jednym gnieździe może odbywać się naraz operacja odczytu i zapisu, do obsługi tych operacji potrzeba tylko dwóch instancji obiektu, którego klasa implementuje IValueTaskSource – m.in. dzięki temu implementacja gniazd jest tak efektywna.

Pokaż mi swoje liczby

Możemy gdybać na temat wzrostu wydajności, ale warto przyjrzeć się liczbom. Do tego celu użyję wyników, które zaprezentował Microsoft w swoim poście. W kategorii Networking możemy znaleźć przykład wysyłania i odbierania danych na pojedynczym gnieździe sieciowym. Poniżej załączam wyniki:

  • SocketReceiveThenSend – .NET Core 2.0 – 102.82 ms,
  • SocketReceiveThenSend – .NET Core 2.1 – 48.95 ms.

Jak widać, na tak podstawowej operacji, jaką jest wysyłanie i odbieranie danych z sieci, oszczędzamy 50% czasu. To bardzo dużo, biorąc pod uwagę niezauważalne zmiany z punktu widzenia użytkownika frameworka.

Asynchroniczność jest wszędzie

Droga przebyta przez programowanie asynchroniczne nie była łatwa. W dodatku – jak zobaczyliśmy powyżej – kolejne etapy jego ewolucji często były wręcz rewolucyjne. Kierunek rozwoju zarówno języka C#, jak i całego frameworka, a także wprowadzanie coraz większej liczby elementów programowania systemowego wskazują jednak, że jeszcze wydajniejsze przetwarzanie jest nieuniknione. Najwyższy czas wycisnąć 200% asynchronicznej normy, używając .NET Core 2.1.

Nie przegap kolejnych postów!

Dołącz do ponad 9000 programistów w devstyle newsletter!

Tym samym wyrażasz zgodę na otrzymanie informacji marketingowych z devstyle.pl (doh...). Powered by Seva
Share.

About Author

Microsoft MVP, współzałożyciel DotNetos, architekt, speaker, inżynier oprogramowania oraz lider Warszawskiej Grupy .NET. Z zamiłowaniem łączy architekturę i oprogramowanie wysokiej wydajności. Chętnie dzieli się szeroką wiedzą na temat współczesnych architektur, systemów rozproszonych oraz zasad rządzących niskopoziomowym światem niezwykle wydajnych aplikacji. Nigdy nie alokuje przed 12.

10 Comments

  1. Obecnie jak patrzę na .NET Core 2.1 to jest majstersztyk w swojej klasie. Niech zgadnę, ValueTask stoi na span’nie ? :-)

      • Sprawdziłem implementacje ValueTask, coś mi nie pasowało, struct, object i lekkie, łatwo kopiowalne rozwiązanie. Cała magia w Unsafe.As i emitowania kodu prosto do IL’a, aby pominąć alokacje i przy tym dla typów generycznych, sprytne.

      • Wspomniany przez Ciebie instrinct Unsafe.As potrzebny jest właśnie z powodu przechowywania wszystkich instancji typów referencyjnych w jednym polu. Cały ValueTask, jak i jego awaiter jak i to, w jaki sposób napisać poprawnie IValueTaskSource aby był sensownie reużywalny, to materiał na więcej niż jeden artykuł :)

        Na szczęście, mamy teraz źródła :-)

  2. @Marbel82

    Powiedziałbym, że programowania systemowego, tak abyś mógł napisać wszystko, nawet bazę danych. Co do Unsafe.As – skoro kontrolują wszystkie wejścia do ValueTask (konstruktory i metody) i mogą dowieść, że tam nie jest potrzebne kosztowny check a wystarczy potraktowanie `object` jako IValueTaskSource. W kodzie frameworka/biblioteki jak najbardziej widzę dla tego zastosowanie. W końcu, skorzysta z tego tysiące/miliony aplikacji. Przy pisaniu aplikacji, wystarczy ‘as’ albo rzutowanie.

  3. Świetny post, bardzo merytorycznie, a zarazem jasno wytłumaczone.

    Mam tylko jedno pytanie. Jak takie wywołanie testować pod względem wydajności? Czy pod GetData wstawić jakiegoś stuba i odpalić na tym benchmarkDotNet, czy powinno się porównać jakoś na prawdziwym wykonaniu tej metody?

    Czy ValueTask używałbyś teraz jako domyślnej opcji, czy dopiero wtedy jak będzie problem z wydajnością? Bo jak pisałeś z tą wydajnością nie zawsze może być lepiej przy ValueTask. Teraz testować wydajność każdego użycia tych dwóch konstrukcji to trochę sporo dodatkowej pracy.

    • Dziękuję za tak miły feedback :)

      BenchmarkDotNet – to najlepsze co można zrobić.

      Jeżeli pisałbym aplikację, to używałbym po prostu tasków (domyślna opcja). No może z wyjątkiem cache’owania – tam ścieżka synchroniczna jest wysoce prawdpodobna.

      Jeżeli pisałbym bibliotekę i znał scenariusze użycia i liczbę wywołań metod i np. chciał coś cache’ować, albo batchować, wtedy myślałbym nad tym jak (nad-)użyć ValueTask i IValueTaskSource.

Leave A Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.