W przypadku projektowania aplikacji z użyciem wielowątkowości, istnieje wiele praktycznych rozwiązań, które mogą pomóc w lepszym zarządzaniu zadaniami asynchronicznymi. Jednym z takich rozwiązań jest użycie tzw. "Puppet Task", który pozwala na elastyczne tworzenie zadań asynchronicznych w sytuacjach, kiedy operacja długoterminowa nie jest dostępna jako TAP (Task-based Asynchronous Pattern). Stworzenie takiego zadania może być istotnym elementem efektywnego zarządzania asynchronicznymi operacjami w aplikacji.

Pierwszym ważnym aspektem jest zrozumienie, dlaczego nie zawsze warto, aby to wywołanie API samo zarządzało wątkami. W rzeczywistości, jeśli chodzi o aplikacje internetowe, stosowanie puli wątków nie daje realnych korzyści; w tym przypadku kluczowe jest jedynie optymalizowanie całkowitej liczby wątków. Używanie Task.Run może wydawać się prostym rozwiązaniem, ale lepiej pozostawić to wywołującym API, które ma pełniejszą wiedzę o wymaganiach dotyczących wątków.

Kiedy chcemy zrealizować operację, która nie jest od razu dostępna jako metoda asynchroniczna, lecz wymaga ręcznego zarządzania asynchronicznością, przydatnym narzędziem staje się TaskCompletionSource. Jest to sposób na ręczne utworzenie zadania (Task), które działa niczym "marionetka" — nie jest ono od razu zakończone, a jego ukończenie możemy kontrolować w dowolnym momencie, w tym również wtedy, gdy wystąpi błąd, który chcemy zgłosić.

Przykład zastosowania TaskCompletionSource można zobaczyć, kiedy na przykład chcemy zaimplementować metodę, która wyświetli okno dialogowe użytkownikowi i czeka na jego odpowiedź (np. zgodę na jakąś akcję). W takim przypadku operacja nie jest standardową asynchroniczną metodą (np. zapytaniem do serwera), lecz zależy od interakcji użytkownika z interfejsem.

Załóżmy, że chcemy stworzyć metodę, która wyświetla okno dialogowe, pytając użytkownika o pozwolenie na wykonanie jakiejś czynności w aplikacji. Takie zapytanie może być wywoływane w różnych częściach aplikacji, dlatego warto je zdefiniować w jednym miejscu, aby było łatwo dostępne. Zamiast implementować metodę synchroniczną, która blokuje główny wątek, lepiej będzie uczynić ją asynchroniczną, zwracając kontrolę nad UI, aby użytkownik mógł swobodnie korzystać z aplikacji w czasie, gdy oczekujemy na odpowiedź.

Przykładowa implementacja tej metody wygląda następująco:

csharp
private Task GetUserPermission() { // Tworzymy TaskCompletionSource, by zwrócić "marionetkowe" zadanie TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>(); // Tworzymy dialog PermissionDialog dialog = new PermissionDialog(); // Po zamknięciu dialogu ustawiamy wynik zadania dialog.Closed += delegate { tcs.SetResult(dialog.PermissionGranted); }; // Wyświetlamy dialog dialog.Show(); // Zwracamy zadanie, które jeszcze nie zostało ukończone return tcs.Task; }

Warto zauważyć, że metoda ta nie jest oznaczona jako async. Ponieważ sami tworzymy zadanie (Task), nie potrzebujemy, by kompilator automatycznie generował takie zadanie dla nas. TaskCompletionSource pozwala na ręczne kontrolowanie procesu zakończenia tego zadania, które może być zakończone dopiero po wykonaniu określonej operacji — w tym przypadku po zamknięciu okna dialogowego.

Zaletą tego podejścia jest to, że wywołujący może po prostu użyć metody w sposób asynchroniczny, czekając na wynik odpowiedzi użytkownika:

csharp
if (await GetUserPermission()) { // kod po uzyskaniu zgody użytkownika }

Mimo że rozwiązanie to jest bardzo proste i efektywne, to jednak należy pamiętać o pewnym mankamencie, który może się pojawić w niektórych przypadkach — braku wersji TaskCompletionSource, która nie jest generyczna. Niemniej jednak, ponieważ Task jest podklasą Task<T>, można używać TaskCompletionSource wszędzie tam, gdzie potrzebny jest zwykły Task, a zwrócone przez nas zadanie będzie w pełni poprawne.

Puppet Task jest więc doskonałym narzędziem, gdy musimy zbudować mechanizm asynchroniczny, ale nie mamy dostępu do standardowych, opartych na TAP, metod. Umożliwia to pełną kontrolę nad tym, kiedy i jak zakończyć zadanie, oraz pozwala w pełni integrować takie operacje z istniejącymi asynchronicznymi metodami w aplikacji.

Dzięki temu mechanizmowi, aplikacja staje się bardziej elastyczna i przyjazna użytkownikowi, a operacje asynchroniczne są bardziej kontrolowane i mniej narażone na błędy wynikające z nadmiernego zarządzania wątkami lub nieodpowiedniego skalowania operacji.

Jak zarządzać wątkami i operacjami asynchronicznymi w aplikacjach UI i webowych?

W kontekście aplikacji z interfejsem użytkownika (UI) i aplikacji internetowych, szczególną uwagę należy zwrócić na mechanizm asynchroniczny, który pozwala na wykonywanie operacji bez blokowania głównego wątku. Mechanizm ten, choć ułatwia pracę programistów, wymaga zrozumienia wielu subtelnych szczegółów dotyczących wątków i synchronizacji.

Pierwsza kwestia, którą należy wyjaśnić, to jak działa kod przed pierwszym słowem kluczowym await. W przypadku aplikacji typu UI, kod przed pierwszym wywołaniem await zostaje uruchomiony na głównym wątku UI. Z kolei w aplikacjach ASP.NET, kod ten jest uruchamiany w wątku roboczym ASP.NET. Zwykle przed await wywołuje się kolejną metodę asynchroniczną, która z kolei staje się wyrażeniem czekającym na zakończenie. Wyrażenie to musi być również wykonane na wątku, który wywołał metodę, co powoduje, że ten wątek kontynuuje wykonywanie kodu, aż do momentu, w którym faktycznie otrzyma obiekt typu Task. Tego typu metody mogą pochodzić zarówno z frameworków, jak i być implementowane ręcznie przez programistę z wykorzystaniem TaskCompletionSource.

Należy pamiętać, że sam mechanizm asynchroniczny nie gwarantuje, że aplikacja UI będzie działała płynnie. Nawet jeśli wywołujesz kod asynchroniczny, to wszystko, co wykonuje się przed pierwszym await, jest obsługiwane przez główny wątek UI, który może w tym czasie stać się nieodpowiedzialny. Jeśli zauważymy, że nasza aplikacja działa wolno, warto użyć narzędzi do profilowania wydajności, aby dokładnie sprawdzić, gdzie traci się najwięcej czasu. Choć kod asynchroniczny pozwala na uniknięcie blokowania wątków, nie zawsze oznacza to pełną optymalizację pod względem wydajności.

Podczas działania operacji asynchronicznej pojawia się pytanie, który wątek wykonuje konkretną operację. W kontekście typowych operacji asynchronicznych, takich jak zapytania sieciowe, żaden wątek nie jest bezpośrednio zablokowany na czas oczekiwania. Na przykład, kiedy oczekujemy na odpowiedź z sieci, zapytanie jest realizowane przez jeden z wątków przeznaczonych do obsługi operacji we/wy (I/O completion port) w systemie Windows. W przypadku dużej liczby równoczesnych żądań sieciowych, wszystkie one są obsługiwane przez ten sam wątek I/O, który jest odpowiedzialny za obsługę zakończenia operacji i przekazywanie wyników do aplikacji. W praktyce, w systemach wielordzeniowych, wykorzystywanych jest kilka wątków I/O, ale ich liczba nie zmienia się zależnie od liczby oczekujących zapytań.

Ważnym elementem zarządzania asynchronią w .NET jest klasa SynchronizationContext. To abstrakcja, która pozwala na kontrolowanie, w którym wątku ma być wykonywany dany kod. Klasa ta jest szczególnie istotna w aplikacjach UI, takich jak WinForms czy WPF, które muszą mieć zapewnioną możliwość wykonywania kodu w głównym wątku UI. SynchronizationContext przechowuje informacje o bieżącym kontekście wątku, co pozwala później, po zakończeniu oczekiwania na operację asynchroniczną, kontynuować jej wykonanie na odpowiednim wątku. Na przykład, w aplikacjach UI, SynchronizationContext zapewnia, że kod po wywołaniu await zostanie wznowiony na tym samym wątku UI, na którym rozpoczęło się wywołanie metody.

Sama metoda Post jest podstawowym mechanizmem umożliwiającym uruchomienie delegata w odpowiednim kontekście wątku. Działa to również w sytuacjach, gdy kontekst wątku obejmuje więcej niż jeden wątek, na przykład w przypadku wątków puli wątków. Istnieją również scenariusze, w których SynchronizationContext nie zmienia wątku, na przykład w przypadku aplikacji konsolowych, które nie mają kontekstu UI. Oczywiście, w przypadku takich aplikacji, proces wznowienia asynchronicznej operacji może odbywać się na dowolnym wątku, a niekoniecznie na tym samym, na którym operacja została początkowo rozpoczęta.

Większość aplikacji UI nie musi się martwić o szczegóły synchronizacji, ponieważ SynchronizationContext w tych środowiskach zawsze dba o to, by kod po await był wznowiony w kontekście głównego wątku. Dzięki temu manipulowanie elementami interfejsu użytkownika po wykonaniu operacji asynchronicznej nie wiąże się z żadnymi dodatkowymi komplikacjami.

Chcąc zobrazować życie operacji asynchronicznej, warto przyjrzeć się przykładzie, w którym użytkownik klika przycisk w aplikacji, wywołując metodę asynchroniczną. Proces zaczyna się od kliknięcia przycisku, po czym wykonana zostaje część kodu w głównym wątku UI. Następnie kod wywołuje inną metodę asynchroniczną, która rozpoczyna operację sieciową. W momencie oczekiwania na dane, główny wątek UI nie jest zablokowany, a wątek I/O przejmuje kontrolę nad oczekiwaniem na odpowiedź sieciową. Po zakończeniu pobierania danych wątek I/O wywołuje powrót do głównego wątku UI, aby kontynuować wykonanie kodu i zaktualizować interfejs użytkownika.

Zrozumienie tej dynamiki jest kluczowe dla programistów, którzy chcą tworzyć wydajne aplikacje asynchroniczne. Ważne jest, by pamiętać, że operacje asynchroniczne nie oznaczają jednocześnie, że aplikacja stanie się automatycznie szybsza. Kluczem do sukcesu jest właściwe zarządzanie wątkami i synchronizacją oraz unikanie błędów związanych z zablokowaniem głównego wątku UI, co może skutkować spadkiem wydajności lub opóźnieniami w odpowiedziach na interakcje użytkownika.