Async/await w C# - Task, TAP, sync over async i Task.WhenAll
Asynchroniczne programowanie w C# - słowa kluczowe async i await, model TAP, typ Task i Task<T>, antywzorzec sync over async (.Result/.Wait), Task.WhenAll dla równoległej kompozycji, wyjątki w async i niebezpieczeństwo async void. Dwunasta część kursu C#.
W poprzednim wpisie Synchronizacja wątków w C# zobaczyłeś, jak chronić wspólne dane przed wyścigami. Jeden z zakazów był szczególnie surowy: await w bloku lock jest zabronione. Czas zrozumieć dlaczego - i poznać model, który tę zasadę dyktuje. async/await to nie kolejny mechanizm wielowątkowości - to fundamentalnie inne podejście do problemu czekania, które nie blokuje wątków.
W tej części przejdziemy przez model Task-based Asynchronous Pattern (TAP) od podstaw - kiedy używać, jak działa pod spodem (state machine), gdzie idą wyjątki, jak komponować wiele zadań przez Task.WhenAll i czemu .Result/.Wait() to anti-pattern, którego trzeba unikać.
1. Synchroniczność kontra asynchroniczność #
Dokumentacja Microsoftu używa wybornej analogii ze śniadaniem. Pomyślmy o tym z perspektywy polskiej kuchni.
Podejście synchroniczne (blokujące): wstawiasz wodę na herbatę. Stoisz przy czajniku i nieruchomo patrzysz na niego trzy minuty. Dopiero gdy się zagotuje, idziesz włożyć chleb do tostera. Znów stoisz, czekasz. Z każdym kolejnym zadaniem - znów stoisz.
Podejście asynchroniczne (nieblokujące): włączasz czajnik. Zamiast czekać, natychmiast idziesz do tostera, potem zaczynasz kroić warzywa. Reagujesz na czajnik dopiero wtedy, kiedy gwizdek Cię zawoła. Jedna osoba, jedna para rąk - ale wiele zadań postępuje równocześnie w tle.
W świecie IT to dokładnie ta sama różnica:
| Cecha | Synchroniczne | Asynchroniczne |
|---|---|---|
| Wątek podczas czekania na I/O | Zamrożony - nic nie robi, zużywa pamięć i slot w puli | Uwolniony do obsługi innych żądań |
| Responsywność UI | Aplikacja “zacina się” przy pobieraniu danych | UI pozostaje płynne |
| Skalowalność serwera | Pula wątków szybko się wyczerpuje | Ten sam serwer obsługuje wielokrotnie więcej żądań |
| Filozofia | ”Czekam aż się skończy" | "Daj znać, kiedy będzie gotowe” |
Ważne rozróżnienie: asynchroniczność nie jest tym samym co wielowątkowość. Cytat MS: Cooking breakfast is a good example of asynchronous work that isn’t parallel. One person can make breakfast asynchronously by starting the next task before the previous task completes. Wielowątkowość daje równoległość (wymaga wielu rdzeni); asynchroniczność daje współbieżność na jednym wątku.
Task-based Asynchronous Pattern (TAP) #
C# ma wbudowane wsparcie dla modelu TAP - Task-based Asynchronous Pattern. Cytat MS: C# has a language-level asynchronous programming model that allows you to easily write asynchronous code without having to juggle callbacks. Trzy filary modelu:
TaskiTask<T>- obiekty reprezentujące “obietnicę” wartości (znanej w przyszłości)async- modyfikator metody, który zezwala na użycieawaitwewnątrzawait- operator, który zawiesza metodę i zwalnia wątek do czasu zakończenia zadania
2. async i await - jak to działa pod spodem #
Te dwa słowa działają zawsze w duecie. await można użyć wyłącznie w metodzie oznaczonej jako async.
Modyfikator async #
async na metodzie nie tworzy nowego wątku. Cytat MS: the compiler transforms your program into a state machine. Co dokładnie się dzieje:
- Kompilator widzi
asynci analizuje ciało metody pod kątemawait. - Cała metoda zostaje przepisana w wewnętrzną maszynę stanów - klasę z polami przechowującymi stan między oczekiwaniami.
- Każde
awaitto punkt zawieszenia - metoda może w tym miejscu “wyjść”, a potem wrócić w to samo miejsce z zachowanym kontekstem.
Operator await #
await task wykonuje trzy rzeczy:
- Sprawdza, czy zadanie już się zakończyło. Jeśli tak (np. wartość była w cache) - metoda kontynuuje bez przerwy.
- Jeśli nie - zawiesza metodę i zwraca sterowanie do wywołującego (
yields control to the caller, cytat MS). Wątek jest uwolniony - może obsługiwać inne zadania. - Gdy task się kończy, scheduler wznawia metodę od miejsca po
await- potencjalnie na innym wątku.
Typ zwracany metody async #
Metoda async zwraca jeden z trzech typów:
| Typ zwracany | Kiedy | Przykład |
|---|---|---|
Task | Brak wartości do zwrócenia | async Task SaveAsync() |
Task<T> | Zwraca wartość typu T | async Task<string> GetWeatherAsync() |
void | Tylko event handlery | async void OnButtonClick(...) |
public class WeatherService
{
public async Task<string> GetWeatherAsync(string city)
{
Console.WriteLine($"[1] Starting download for: {city}");
HttpClient client = new HttpClient();
// await: wątek wychodzi z metody i idzie do innej pracy
// Metoda zostaje "zaparkowana" do czasu odpowiedzi serwera
string response = await client.GetStringAsync($"https://api.weather.com/{city}");
// Ta linia wykona się DOPIERO gdy dane wrócą - być może na innym wątku
Console.WriteLine($"[2] Finished download for: {city}");
return response;
}
}
Konwencja nazewnicza: sufiks Async #
Cytat MS: The .NET style convention is to add the “Async” suffix to all asynchronous method names. Sufiks pozwala na pierwszy rzut oka rozpoznać, że metoda zwraca Task i wymaga await. Wyjątki - event handlery (OnButtonClick) i akcje kontrolerów web (Index) - bo nie są wywoływane przez Twój kod jawnie.
3. Anti-pattern: sync over async #
Najgorszy błąd przy nauce async/await: próba wymuszenia na asynchronicznym kodzie zachowania synchronicznego. Cytat MS:
Synchronous blocking on asynchronous operations can lead to deadlocks and should be avoided whenever possible.
Trzy “śmiertelne grzechy”:
Task.Wait() i Task.Result #
// ŹLE - blokuje wątek, eliminuje cały sens async
public string GetWeatherBlocking()
{
var service = new WeatherService();
string weather = service.GetWeatherAsync("Warszawa").Result; // BLOKADA
return weather;
}
.Result i .Wait() blokują bieżący wątek do zakończenia zadania - to dokładnie ten problem, który async miało rozwiązać. W aplikacjach z kontekstem synchronizacji (UI WinForms/WPF, klasyczny ASP.NET) potrafią dodatkowo wywołać deadlock: wątek czeka na zadanie, które chce wrócić na ten sam wątek, by zakończyć kontynuację. Wieczna pętla.
Thread.Sleep w async #
// ŹLE - blokuje wątek na 1 sekundę
public async Task BadDelay()
{
Thread.Sleep(1000); // wątek "śpi" - blokada
await DoWorkAsync();
}
// DOBRZE - uwalnia wątek na czas opóźnienia
public async Task GoodDelay()
{
await Task.Delay(1000); // wątek jest wolny, scheduler obudzi metodę
await DoWorkAsync();
}
Tabela zamian z dokumentacji MS #
| Anti-pattern | Czego użyć |
|---|---|
task.Wait() | await task |
task.Result | await task |
Task.WaitAny(...) | await Task.WhenAny(...) |
Task.WaitAll(...) | await Task.WhenAll(...) |
Thread.Sleep(ms) | await Task.Delay(ms) |
Async “all the way” - asynchroniczność wirusowa #
Cytat z MS: Using synchronous code when asynchronous alternatives exist hurts your ability to scale out less expensively. You pay for blocked threads. Najlepiej, gdy cały łańcuch wywołań jest async - od kontrolera/UI do API/bazy. Próby “wmiksowania” await w środku synchronicznej metody zwykle prowadzą do .Result, który zabija korzyści. Asynchroniczność powinna “wypływać” od dołu (I/O) do góry (entry point) - stąd żart o “wirusowej naturze” async.
// DOBRZE - async od dołu do góry
public async Task<string> ControllerAction()
{
return await _service.GetWeatherAsync("Warszawa");
}
// ŹLE - przerwany łańcuch, .Result blokuje
public string ControllerAction()
{
return _service.GetWeatherAsync("Warszawa").Result;
}
4. Task.WhenAll - kompozycja równoległych zadań #
Sekwencyjne await to jedna ścieżka. Ale gdy operacje nie zależą od siebie, można je odpalić wszystkie naraz i poczekać raz na koniec.
Sekwencyjnie kontra równolegle #
// SEKWENCYJNIE - czeka 100 + 200 + 300 = 600 ms łącznie
public async Task<int> DownloadSequential()
{
int a = await DownloadAsync("a.txt", 100); // 100 ms
int b = await DownloadAsync("b.txt", 200); // +200 ms (zaczyna PO a)
int c = await DownloadAsync("c.txt", 300); // +300 ms (zaczyna PO b)
return a + b + c;
}
// RÓWNOLEGLE - startuje wszystkie naraz, czeka max(100, 200, 300) = 300 ms
public async Task<int> DownloadParallel()
{
Task<int> ta = DownloadAsync("a.txt", 100); // start od razu
Task<int> tb = DownloadAsync("b.txt", 200); // start od razu
Task<int> tc = DownloadAsync("c.txt", 300); // start od razu
int[] sizes = await Task.WhenAll(ta, tb, tc);
return sizes.Sum();
}
Kluczowy szczegół: wywołanie metody async startuje zadanie - await jedynie czeka na jego wynik. Jeśli przypiszesz wynik do zmiennej Task<T> bez await, zadanie już biegnie w tle. Trzy takie wywołania z rzędu = trzy równoległe zadania.
Co zwraca Task.WhenAll #
Task.WhenAll(params Task[])→Task- kończy się, gdy wszystkie się zakończąTask.WhenAll<T>(params Task<T>[])→Task<T[]>- tablica wyników w kolejności argumentów (nie w kolejności zakończenia)
Task.WhenAny - kto pierwszy #
Task<string> primary = FetchFromPrimaryAsync();
Task<string> fallback = FetchFromFallbackAsync();
// Czekam na pierwszego, który skończy - wzorzec "redundancja na wypadek awarii"
Task<string> firstCompleted = await Task.WhenAny(primary, fallback);
string result = await firstCompleted;
Task.WhenAny zwraca zakończony task (nie wynik) - jeszcze trzeba na niego await, by wyciągnąć wartość lub rzucić wyjątek. Przydatne dla timeoutów, fallbacków, progressivnego pokazywania wyników.
5. Wyjątki w metodach async #
Wyjątek rzucony w metodzie async nie jest natychmiast propagowany - jest przechowywany w zwracanym Task.
public async Task<string> RiskyAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("Something broke");
}
// Wywołanie:
Task<string> task = RiskyAsync(); // nie rzuca - wyjątek jest "schowany"
string result = await task; // TUTAJ wyjątek "wybucha"
Task.Exception to typ AggregateException z kolekcją InnerExceptions (bo zadanie mogło wewnątrz odpalić wiele równoległych operacji). Przy await na faulted task pierwszy InnerException jest rzucany ponownie - dzięki temu try/catch działa naturalnie, jak w synchronicznym kodzie:
try
{
string result = await RiskyAsync();
}
catch (InvalidOperationException ex)
{
// Łapiemy oryginalny wyjątek, nie AggregateException
Console.WriteLine($"Caught: {ex.Message}");
}
Synchroniczna walidacja argumentów #
Cytat MS: The recommended practice is for any argument validation exceptions to emerge synchronously from task-returning methods. Walidacje argumentów rób przed pierwszym await - wtedy ArgumentNullException wybuchnie natychmiast (jak w synchronicznej metodzie), a nie po await. Wzorzec: zewnętrzna nieasynchroniczna metoda waliduje + woła wewnętrzną async:
public Task<Toast> ToastBreadAsync(int slices)
{
// Walidacja SYNCHRONICZNA - rzuca natychmiast
if (slices < 1 || slices > 4)
throw new ArgumentException("Must be 1-4 slices", nameof(slices));
return ToastBreadAsyncCore(slices);
static async Task<Toast> ToastBreadAsyncCore(int s)
{
await Task.Delay(s * 1000);
return new Toast();
}
}
6. async void - tylko dla event handlerów #
async void to wyjątek od reguły. Dozwolone wyłącznie dla event handlerów - bo framework wymaga sygnatury void w delegatce eventu (np. EventHandler przyjmuje (object sender, EventArgs e) i zwraca void).
// OK - event handler musi mieć void
private async void OnDownloadClick(object sender, EventArgs e)
{
string data = await DownloadAsync();
UpdateUI(data);
}
W innych miejscach async void to pułapka:
| Problem | Konsekwencja |
|---|---|
| Wyjątki nie propagują się do wywołującego | try/catch wokół wywołania nie złapie wyjątku - zwykle ubija proces |
Brak Task do await | Wywołujący nie wie, kiedy zadanie się skończy |
| Trudne testowanie | Test nie ma jak poczekać na zakończenie |
Zasada: jeśli metoda nie jest event handlerem - zawsze Task albo Task<T>.
7. I/O-bound kontra CPU-bound - kiedy Task.Run #
Dokumentacja MS dzieli pracę asynchroniczną na dwa typy:
| Typ | Czemu wątek czeka | Wzorzec |
|---|---|---|
| I/O-bound | Sieć, dysk, baza, plik - praca dzieje się gdzie indziej | await ApiClient.GetAsync(...) (bez Task.Run) |
| CPU-bound | Lokalne intensywne obliczenia (kompresja, kryptografia) | await Task.Run(() => HardCalculation()) |
// I/O-bound - HttpClient już zwraca Task, await wystarcza
public async Task<string> FetchUserAsync(int id)
{
return await _httpClient.GetStringAsync($"/users/{id}"); // OK
}
// CPU-bound - zwyczajna metoda nie jest async, ale wrzucamy ją do puli
public async Task<int> ProcessImageAsync(byte[] data)
{
return await Task.Run(() => HeavyImageProcessing(data)); // OK - wyrzucamy z UI
}
Częsty błąd: owijanie wywołań I/O w Task.Run. To dodaje zbędną alokację wątku z puli, podczas gdy I/O nie potrzebuje wątku, by czekać. await HttpClient.GetStringAsync(...) jest lepsze niż await Task.Run(() => HttpClient.GetStringAsync(...).Result).
8. ConfigureAwait(false) - krótko #
task.ConfigureAwait(false) mówi schedulerowi: po kontynuacji nie musisz wracać na ten sam kontekst synchronizacji (UI thread, ASP.NET context).
- W kodzie biblioteki (kod, który będzie używany przez różne aplikacje): używaj
ConfigureAwait(false)na każdymawait. Pomaga uniknąć deadlocków, gdy wywołujący błędnie zrobi.Result. - W kodzie aplikacji UI/ASP.NET klasycznym: zostaw domyślne
true- czasem kontynuacja musi wrócić na UI thread, by zaktualizować kontrolkę. - W ASP.NET Core: kontekst synchronizacji nie istnieje -
ConfigureAwait(false)nie zmienia praktycznie nic.
Pełna analiza wymaga osobnego wpisu (Microsoft ma dedykowane FAQ). Zasada minimum: w bibliotekach NuGet → zawsze ConfigureAwait(false).
Podsumowanie tematu #
W tej części kursu poznałeś:
- Synchroniczność kontra asynchroniczność - asynchroniczność to nie wielowątkowość; jedna osoba może asynchronicznie startować wiele zadań
- Task-based Asynchronous Pattern (TAP) - model oficjalny w .NET, oparty o
Task/Task<T>+async/await asynciawait- kompilator buduje maszynę stanów;awaitzawiesza metodę i zwalnia wątek (cytat MS yields control to the caller)- Typy zwracane -
Task/Task<T>/void(tylko event handlery); konwencja sufiksuAsync - Anti-pattern sync over async -
.Result/.Wait()/Thread.Sleepwasyncblokują wątek i mogą wywołać deadlock; tabela zamian z dokumentacji MS Task.WhenAll- kompozycja zadań w kolejności argumentów, daje równoległe wykonanie kosztu max zamiast sumaTask.WhenAny- pierwszy zakończony task; wzorce timeoutów i fallbacków- Wyjątki - przechowywane w
Task.Exception(AggregateException); pierwszyInnerExceptionrzucany ponownie przyawait; walidacja argumentów synchronicznie przed pierwszymawait async void- wyłącznie event handlery; gdzie indziej pułapka (wyjątki nie do złapania, brakTask, trudne testy)- I/O-bound kontra CPU-bound - I/O wystarczy
await; CPU-bound przezawait Task.Run(...); nigdy nie owijaj I/O wTask.Run ConfigureAwait(false)- w bibliotekach zawsze; w UI/klasycznym ASP.NET zostaw domyślne
W następnym wpisie Maszyna stanów async w C# zajrzymy pod maskę - co kompilator Roslyn naprawdę robi z metodami async. Zobaczysz, jak Twoja zwykła metoda zamienia się w strukturę implementującą IAsyncStateMachine, dlaczego zmienne lokalne stają się polami i czemu CLR w ogóle nie zna pojęcia await.
Quiz i zadanie poniżej. Zadanie demonstruje sednem Task.WhenAll - trzy “pobierania” równolegle z deterministycznym wynikiem dzięki kolejności argumentów (Task.WhenAll<T> zwraca tablicę w tej samej kolejności, niezależnie od kolejności zakończenia).
Podobne wpisy
Memory i ReadOnlyMemory w C# - asynchroniczny krewniak Spana
Memory<T> jako obietnica Span<T> przeżywająca await, ReadAsync z buforem, ReadOnlyMemory<char> dla tekstu w async, IMemoryOwner i MemoryPool dla recyklingu, lifetime rules - reguły 3-8 z dokumentacji MS o czasie życia bufora. Osiemnasta część kursu C#.
Deadlocki i wycieki pamięci w async C# - sync-over-async, fire-and-forget, CancellationToken.Register
Trzy najgroźniejsze pułapki async w produkcji - klasyczny deadlock z .Result/.Wait(), połknięte wyjątki z fire-and-forget, wycieki pamięci z CancellationToken.Register. Diagnostyka i wzorce obronne. Szesnasta i ostatnia część kursu C# o asynchroniczności.
Zaawansowany async w C# - ConfigureAwait, CancellationToken, IAsyncEnumerable
Trzy mechanizmy programisty Senior - ConfigureAwait(false) eliminujący niepotrzebne wracanie do kontekstu synchronizacji, CancellationToken jako kooperatywne anulowanie, IAsyncEnumerable jako strumień asynchroniczny z await foreach. Piętnasta część kursu C#.
🔗 Linkują tu