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.