Wszystkie wpisy

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:

CechaSynchroniczneAsynchroniczne
Wątek podczas czekania na I/OZamrożony - nic nie robi, zużywa pamięć i slot w puliUwolniony do obsługi innych żądań
Responsywność UIAplikacja “zacina się” przy pobieraniu danychUI pozostaje płynne
Skalowalność serweraPula wątków szybko się wyczerpujeTen 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:

  • Task i Task<T> - obiekty reprezentujące “obietnicę” wartości (znanej w przyszłości)
  • async - modyfikator metody, który zezwala na użycie await wewnątrz
  • await - 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:

  1. Kompilator widzi async i analizuje ciało metody pod kątem await.
  2. Cała metoda zostaje przepisana w wewnętrzną maszynę stanów - klasę z polami przechowującymi stan między oczekiwaniami.
  3. Każde await to 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:

  1. Sprawdza, czy zadanie już się zakończyło. Jeśli tak (np. wartość była w cache) - metoda kontynuuje bez przerwy.
  2. 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.
  3. 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 zwracanyKiedyPrzykład
TaskBrak wartości do zwróceniaasync Task SaveAsync()
Task<T>Zwraca wartość typu Tasync Task<string> GetWeatherAsync()
voidTylko event handleryasync 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-patternCzego użyć
task.Wait()await task
task.Resultawait 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:

ProblemKonsekwencja
Wyjątki nie propagują się do wywołującegotry/catch wokół wywołania nie złapie wyjątku - zwykle ubija proces
Brak Task do awaitWywołujący nie wie, kiedy zadanie się skończy
Trudne testowanieTest 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:

TypCzemu wątek czekaWzorzec
I/O-boundSieć, dysk, baza, plik - praca dzieje się gdzie indziejawait ApiClient.GetAsync(...) (bez Task.Run)
CPU-boundLokalne 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żdym await. 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
  • async i await - kompilator buduje maszynę stanów; await zawiesza metodę i zwalnia wątek (cytat MS yields control to the caller)
  • Typy zwracane - Task / Task<T> / void (tylko event handlery); konwencja sufiksu Async
  • Anti-pattern sync over async - .Result/.Wait()/Thread.Sleep w async blokują 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 suma
  • Task.WhenAny - pierwszy zakończony task; wzorce timeoutów i fallbacków
  • Wyjątki - przechowywane w Task.Exception (AggregateException); pierwszy InnerException rzucany ponownie przy await; walidacja argumentów synchronicznie przed pierwszym await
  • async void - wyłącznie event handlery; gdzie indziej pułapka (wyjątki nie do złapania, brak Task, trudne testy)
  • I/O-bound kontra CPU-bound - I/O wystarczy await; CPU-bound przez await Task.Run(...); nigdy nie owijaj I/O w Task.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

🔗 Linkują tu