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#
-
asyncpozwala używaćawaitw ciele metody, ale samo w sobie nie tworzy nowego wątku. -
awaitnie blokuje wątku podczas oczekiwania na operację wejścia-wyjścia. - Metody asynchroniczne zwykle zwracają
TaskalboTask. -
Task.WhenAllpozwala uruchomić niezależne operacje współbieżnie i poczekać na ich zakończenie. - Najczęstsze błędy to
.Result,.Wait()iasync voidużywane poza uzasadnionymi wyjątkami.

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
TasklubTask, aasync voidzostaw dla zdarzeń. - Nie blokuj zadań przez
.Resulti.Wait(). - Używaj
Task.WhenAlltylko 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.
