• C# i .NET
  • Async i await w C# bez blokowania wątku - praktyczny przewodnik

Async i await w C# bez blokowania wątku - praktyczny przewodnik

Wizualizacja pokazuje, jak wątek T2, reprezentujący c# async, jest przetwarzany przez CPU, podczas gdy kontekst wątku T1 znajduje się w pamięci.

Spis treści

Gdy aplikacja pobiera dane z API, zapisuje plik albo czeka na odpowiedź bazy danych, nie powinna bezczynnie blokować wątku. Właśnie tutaj przydaje się asynchroniczność w C#, oparta na słowach kluczowych async i await. Pokażę, jak działa ten mechanizm, kiedy używać Task.WhenAll, jak obsługiwać błędy i anulowanie oraz których popularnych pułapek unikać.

Najważniejsze zasady pisania kodu asynchronicznego w C#

  • async pozwala używać await w ciele metody, ale samo w sobie nie tworzy nowego wątku.
  • await nie blokuje wątku podczas oczekiwania na operację wejścia-wyjścia.
  • Metody asynchroniczne zwykle zwracają Task albo Task.
  • Task.WhenAll pozwala uruchomić niezależne operacje współbieżnie i poczekać na ich zakończenie.
  • Najczęstsze błędy to .Result, .Wait() i async void używane poza uzasadnionymi wyjątkami.

Diagram przedstawia działanie metody MoveNext w maszynie stanów, wykorzystując c# async. Pokazuje przejścia między stanami, operacje GetResult i wywołania Task.Delay.

C# async i await czyli co naprawdę dzieje się w metodzie

W praktyce async oznacza, że metoda może zawierać wyrażenie await. Gdy program dochodzi do oczekiwania na niezakończone zadanie, metoda zostaje wstrzymana, a bieżący wątek może zająć się inną pracą. Po zakończeniu operacji wykonanie wraca do miejsca, w którym nastąpiło oczekiwanie.

Nie oznacza to automatycznie uruchomienia kodu na osobnym wątku. Przy operacjach takich jak żądanie HTTP, odczyt pliku czy zapytanie do bazy najwięcej czasu zajmuje oczekiwanie na zewnętrzny system. Asynchroniczność pozwala wtedy lepiej wykorzystać dostępne zasoby, szczególnie w aplikacjach webowych i interfejsach użytkownika.

public async Task GetUserNameAsync(int userId)
{
    var response = await _httpClient.GetStringAsync($"api/users/{userId}");
    return response;
}

Typ zwracany przez metodę mówi, co otrzyma kod wywołujący. Task oznacza operację bez wyniku, a Task operację zwracającą wartość typu T. W nowoczesnym C# metoda wejściowa aplikacji również może być asynchroniczna, więc nie trzeba blokować programu sztucznym oczekiwaniem.

Sygnatura Zastosowanie
async Task Operacja asynchroniczna bez wartości zwracanej.
async Task Operacja asynchroniczna zwracająca wynik.
async void Głównie procedury obsługi zdarzeń w aplikacjach UI.
ValueTask Specjalistyczny wariant przy ścieżkach wrażliwych na alokacje.

Sam modyfikator async nie przyspieszy obliczeń procesora. Jeśli metoda wykonuje ciężkie obliczenia, a nie czeka na I/O, potrzebujesz innego podejścia, na przykład świadomego użycia Task.Run lub osobnego mechanizmu przetwarzania. To rozróżnienie jest jednym z miejsc, w których początkujący najczęściej przypisują asynchroniczności zbyt szerokie możliwości.

Jak pisać metody asynchroniczne bez blokowania

Najprostsza zasada brzmi: jeżeli wywołujesz metodę asynchroniczną, również oczekuj na nią przez await. Dzięki temu asynchroniczność może przejść przez cały łańcuch wywołań, zamiast kończyć się w jednym miejscu blokującym wątek.

public async Task LoadOrderAsync(
    int orderId,
    CancellationToken cancellationToken)
{
    using var response = await _httpClient.GetAsync(
        $"api/orders/{orderId}",
        cancellationToken);

    response.EnsureSuccessStatusCode();

    return await response.Content.ReadFromJsonAsync(
        cancellationToken: cancellationToken);
}

public async Task PrintOrderAsync(
    int orderId,
    CancellationToken cancellationToken)
{
    var order = await LoadOrderAsync(orderId, cancellationToken);
    Console.WriteLine(order.Number);
}

W tym przykładzie wątek nie jest zajęty przez cały czas trwania żądania. Metoda oddaje sterowanie, a po otrzymaniu odpowiedzi kontynuuje pracę. Dla serwera obsługującego wiele żądań oznacza to większą przepustowość bez ręcznego tworzenia wątku dla każdego klienta.

Nie zamieniaj jednak bezmyślnie każdej metody na asynchroniczną. Jeśli kod wykonuje krótkie, czysto synchroniczne operacje w pamięci, dodanie async może tylko skomplikować API. Największy sens pojawia się wtedy, gdy metoda rzeczywiście czeka na zasób zewnętrzny.

Dlaczego .Result i .Wait() są ryzykowne

Poniższy kod wygląda niewinnie, ale blokuje bieżący wątek:

var user = LoadUserAsync().Result;

W aplikacjach korzystających z kontekstu synchronizacji może to prowadzić do zakleszczenia, czyli sytuacji, w której kod czeka na kontynuację, a kontynuacja czeka na zwolnienie zablokowanego wątku. Nawet gdy zakleszczenie nie wystąpi, blokowanie ogranicza skalowalność i utrudnia obsługę wyjątków.

Preferuję zasadę „async all the way”, czyli asynchroniczny łańcuch od punktu wejścia aż do warstwy wykonującej operację. Gdy synchronizacja jest absolutnie konieczna na granicy starego API, trzeba traktować ją jako wyjątek i dokładnie sprawdzić, na jakim kontekście działa kod.

Kiedy uruchamiać zadania równolegle przez Task.WhenAll

Jeśli kilka operacji jest od siebie niezależnych, nie musisz wykonywać ich jedna po drugiej. W takim przypadku najpierw uruchom zadania, a dopiero później zaczekaj na wszystkie za pomocą Task.WhenAll.

var profileTask = LoadProfileAsync(userId);
var permissionsTask = LoadPermissionsAsync(userId);
var notificationsTask = LoadNotificationsAsync(userId);

await Task.WhenAll(
    profileTask,
    permissionsTask,
    notificationsTask);

var profile = await profileTask;
var permissions = await permissionsTask;
var notifications = await notificationsTask;

Wersja sekwencyjna czekałaby na profil, potem na uprawnienia i dopiero na powiadomienia. Wersja współbieżna może skrócić czas oczekiwania do czasu najwolniejszej operacji, choć rzeczywisty efekt zależy od API, bazy danych, limitów połączeń i obciążenia serwera.

Sytuacja Lepszy wybór Dlaczego
Druga operacja potrzebuje wyniku pierwszej Osobne await Zachowujesz wymaganą kolejność.
Operacje są niezależne Task.WhenAll Możesz skrócić łączny czas oczekiwania.
Chcesz zareagować na pierwszą zakończoną operację Task.WhenAny Kontynuujesz pracę, gdy gotowe jest pierwsze zadanie.
Operacji jest bardzo dużo Limit współbieżności Chronisz API, bazę i własną aplikację przed przeciążeniem.

Przy zadaniach zwracających wyniki Task.WhenAll zwraca tablicę rezultatów w tej samej kolejności, w której przekazano zadania, a nie w kolejności ich zakończenia. To ważne, gdy wyniki są później przypisywane do konkretnych elementów wejściowych.

Gdy po równoległym pobraniu danych przeliczasz kolekcję, nie myl liczby elementów z pojemnością bufora. W takich sytuacjach przydaje się znajomość różnicy między Count w C# a właściwością Capacity, bo niepotrzebne alokacje potrafią być kosztowne przy dużych wynikach.

Jak obsługiwać wyjątki i anulowanie operacji

Wyjątek z metody asynchronicznej jest zwykle zgłaszany w momencie wykonania await. Dlatego blok try powinien obejmować oczekiwanie na zadanie, a nie tylko samo jego utworzenie.

try
{
    var data = await LoadDataAsync(cancellationToken);
    Process(data);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Operacja została anulowana.");
}
catch (HttpRequestException ex)
{
    Console.WriteLine($"Błąd komunikacji: {ex.Message}");
}

Do anulowania używaj CancellationToken. Sam token niczego nie zatrzymuje, tylko przekazuje sygnał do metody, która musi go obsłużyć. Biblioteki .NET, takie jak HttpClient, przyjmują token w wielu metodach wejścia-wyjścia.

using var timeout = new CancellationTokenSource(
    TimeSpan.FromSeconds(5));

try
{
    var result = await LoadDataAsync(timeout.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Przekroczono limit czasu.");
}

Rozróżniaj anulowanie od błędu aplikacji. OperationCanceledException często oznacza normalny scenariusz, na przykład zamknięcie ekranu przez użytkownika albo przekroczenie limitu czasu. Nie powinno się logować każdego takiego przypadku jako awarii systemu.

Przy wielu zadaniach możesz przekazać ten sam token do wszystkich operacji. Jeśli jedno zadanie kończy się błędem, pozostałe nie zawsze zatrzymają się automatycznie, dlatego w dłuższych procesach warto zaplanować własną strategię anulowania i sprzątania zasobów.

Najczęstsze błędy w kodzie asynchronicznym

Używanie async void bez potrzeby

async void jest uzasadnione głównie dla obsługi zdarzeń, na przykład kliknięcia przycisku. W zwykłej metodzie utrudnia oczekiwanie na zakończenie i przechwytywanie wyjątków. Dla logiki biznesowej wybieraj Task albo Task.

Uruchamianie Task.Run dla każdego await

Nie ma sensu opakowywać wywołania HTTP w Task.Run, ponieważ biblioteka już oferuje asynchroniczną operację wejścia-wyjścia. Task.Run może mieć zastosowanie przy ciężkich obliczeniach synchronicznych, ale użyty bez powodu tylko zajmuje wątki puli.

Fire-and-forget bez kontroli

Kod taki jak _ = SendEmailAsync(); uruchamia operację, ale nie daje pewności, że aplikacja zauważy jej błąd albo poczeka na jej zakończenie. W serwerze może też dojść do sytuacji, w której zakres zależności zostanie zamknięty, zanim zadanie skończy pracę.

Jeżeli zadanie naprawdę ma działać w tle, lepiej użyć kontrolowanego mechanizmu, na przykład kolejki i usługi działającej w tle. Wtedy masz miejsce na retry, logowanie, anulowanie i bezpieczne zarządzanie zakresem obiektów.

Niepotrzebne kopiowanie wyników

Asynchroniczność nie zwalnia z myślenia o pamięci. Jeśli kilka zadań zwraca duże tablice, a potem tworzysz kolejne kopie tylko po to, by zmienić ich format, koszt może przewyższyć zysk z równoległego pobierania danych. Przed takim przekształceniem sprawdź dostępne metody kopiowania tablicy C# i wybierz rozwiązanie adekwatne do rozmiaru danych.

Przeczytaj również: Polskie znaki w CSV i Excelu? Poprawne kodowanie w C# i .NET

Nieprzemyślane ConfigureAwait

W bibliotekach, które nie muszą wracać do kontekstu wywołującego, można rozważyć ConfigureAwait(false). W aplikacjach z interfejsem użytkownika kontekst może być potrzebny do aktualizacji widoku, dlatego nie warto kopiować tej konstrukcji mechanicznie do każdego projektu.

Jak podejść do async w nowym projekcie

Zaczynam od ustalenia, które operacje są wejściem-wyjściem, a które wykonują obliczenia. Dla pierwszej grupy stosuję asynchroniczne API i propaguję CancellationToken, dla drugiej mierzę koszt obliczeń, zanim zdecyduję się na dodatkowe wątki.

  • Kończ nazwy metod asynchronicznych przyrostkiem Async.
  • Zwracaj Task lub Task, a async void zostaw dla zdarzeń.
  • Nie blokuj zadań przez .Result i .Wait().
  • Używaj Task.WhenAll tylko dla niezależnych operacji.
  • Dodawaj anulowanie tam, gdzie użytkownik może przerwać pracę albo gdzie istnieje limit czasu.
  • Mierz rzeczywisty efekt, zamiast zakładać, że większa współbieżność zawsze oznacza większą szybkość.

Najlepszy kod asynchroniczny nie jest tym, który zawiera najwięcej słów async. Jest nim kod, który nie blokuje zasobów podczas oczekiwania, poprawnie obsługuje błędy i potrafi zakończyć pracę wtedy, gdy dalsze czekanie nie ma już sensu. Taki sposób myślenia sprawdza się równie dobrze w aplikacji ASP.NET Core, narzędziu konsolowym, integracji z Azure i kodzie komunikującym się z bazą danych.

FAQ - Najczęstsze pytania

Nie. async umożliwia użycie await, a podczas oczekiwania na operację wejścia-wyjścia bieżący wątek może wykonywać inną pracę. Task.Run ma sens głównie przy ciężkich obliczeniach synchronicznych, a nie przy zwykłych żądaniach HTTP.

Task.WhenAll stosuj wtedy, gdy operacje są od siebie niezależne, na przykład pobierasz profil, uprawnienia i powiadomienia. Wyniki są zwracane w kolejności przekazanych zadań, ale przy dużej liczbie operacji warto ograniczyć współbieżność, aby nie przeciążyć API lub bazy danych.

Obie konstrukcje blokują bieżący wątek i mogą prowadzić do zakleszczenia, gdy kontynuacja czeka na zwolnienie tego samego kontekstu. Bezpieczniejsza jest zasada async all the way, czyli oczekiwanie przez await od punktu wejścia do warstwy wykonującej operację.

Blok try powinien obejmować instrukcję await, ponieważ wyjątek jest zwykle zgłaszany podczas oczekiwania na zadanie. Przekaż CancellationToken do metod wejścia-wyjścia, pamiętając, że sam token nie zatrzymuje pracy. OperationCanceledException często oznacza normalne anulowanie lub przekroczenie limitu czasu, a nie awarię aplikacji.

Oceń artykuł

Ocena: 0.00 Liczba głosów: 0

Tagi

asynchroniczność
współbieżność
anulowanie
cancellationtoken
task.whenall
Autor Przemysław Kwiatkowski
Przemysław Kwiatkowski
Jestem Przemysław i od 15 lat zajmuję się programowaniem .NET, chmurą Azure oraz sztuczną inteligencją. Moja przygoda z tymi technologiami zaczęła się od fascynacji możliwościami, jakie dają, a z czasem przerodziła się w pasję do tworzenia rozwiązań, które realnie wpływają na pracę i życie ludzi. Na kursdotnet.pl staram się dzielić się swoją wiedzą w sposób przystępny, tłumacząc złożone zagadnienia i pomagając zrozumieć, jak te dynamicznie rozwijające się obszary IT mogą być wykorzystane w praktyce. Dokładam wszelkich starań, aby prezentowane przeze mnie materiały były rzetelne, aktualne i oparte na sprawdzonych źródłach, a także aby uporządkować wiedzę w sposób ułatwiający jej przyswojenie.

Udostępnij artykuł

Napisz komentarz