Wszystkie wpisy

Maszyna stanów async w C# - jak kompilator zmienia await na IAsyncStateMachine

Co kompilator Roslyn robi z metodami async pod spodem - generowanie struktury implementującej IAsyncStateMachine, pola na zmienne lokalne, MoveNext jako serce logiki, AsyncTaskMethodBuilder kontrolujący Task. Praktyczne konsekwencje dla wydajności i debugowania. Trzynasta część kursu C#.


W poprzednim wpisie Async/await w C# padło zdanie, które mogło zostawić nieuporządkowane pytanie: “wątek jest zwalniany, a metoda wraca po czasie do tego samego miejsca z zachowanym kontekstem”. Jak to działa? Skoro wątek dosłownie opuszcza metodę, co pamięta wartości zmiennych lokalnych? Skąd wie, w której linii był? Odpowiedzią jest maszyna stanów - struktura, którą kompilator generuje za każdą metodą async.

W tej części zobaczysz, co dokładnie robi kompilator C# (Roslyn) z Twoim kodem async/await. Dlaczego CLR w ogóle nie zna pojęcia await. Czemu kompilator generuje strukturę, a nie klasę. I dlaczego ta wiedza nie jest akademicką ciekawostką - bezpośrednio wpływa na wydajność Twojego kodu i interpretację stack trace’ów.

1. Iluzja async i await #

Cytat z dokumentacji MS o await: suspends evaluation of the enclosing async method until the asynchronous operation represented by its operand completes… The await operator doesn’t block the thread that evaluates the async method.

To opis z perspektywy programisty. Z perspektywy runtime’u nie istnieje ani async, ani await. CLR nie ma instrukcji await, nie ma “metody asynchronicznej” jako gatunku, nie obsługuje pauz w wykonaniu. Co więc widzi runtime?

Widzi zwykły kod IL, który:

  1. Tworzy obiekt struktury (typu, który Ty nigdy nie napisałeś).
  2. Wywołuje na nim metodę MoveNext().
  3. Czeka na “powiadomienie” (notification) z innego zadania.
  4. Po powiadomieniu - znów wywołuje MoveNext() na tej samej strukturze.

Ta struktura, to właśnie maszyna stanów generowana przez kompilator. Bez niej Twoje async/await nie miałoby sensu na poziomie IL - to wyłącznie lukier składniowy, który Roslyn rozkłada na “niskopoziomowe” konstrukcje.

2. Kod źródłowy - to, co piszesz #

Najprostsza metoda async, której będziemy się przyglądać:

public async Task<string> FetchDataAsync()
{
    Console.WriteLine("Start");

    string result = "Data";        // zmienna lokalna

    await Task.Delay(1000);        // pauza

    result += " processed";        // używamy result PO pauzie

    return result;
}

Z perspektywy programisty: cztery linie kodu, pauza w środku, zwykła zmienna lokalna result. Wszystko jasne.

3. Wygenerowany kod - to, co widzi runtime #

Kompilator C# bierze tę metodę i doszczętnie ją przepisuje. Poniżej uproszczona wersja tego, co generuje (oryginał ma wygenerowane przez kompilator nazwy typu <FetchDataAsync>d__0, których nie da się normalnie napisać w C#):

// 1. Oryginalna metoda staje się tylko "włącznikiem"
public Task<string> FetchDataAsync()
{
    // Tworzy instancję struktury maszyny stanów
    var machine = new FetchDataStateMachine();
    machine.builder = AsyncTaskMethodBuilder<string>.Create();
    machine.state = -1;                  // stan początkowy

    // Uruchamia maszynę - to wywołuje MoveNext po raz pierwszy
    machine.builder.Start(ref machine);

    // Zwraca obietnicę (Task), którą kontroluje builder
    return machine.builder.Task;
}

// 2. Wygenerowana struktura maszyny stanów
[CompilerGenerated]
private struct FetchDataStateMachine : IAsyncStateMachine
{
    // --- POLA ---
    public int state;                                    // licznik etapów
    public AsyncTaskMethodBuilder<string> builder;       // kontroler Task-a

    // Zmienne lokalne metody przesunięte do pól!
    private string _result;

    // Awaiter ostatniego oczekiwanego task-a
    private TaskAwaiter _awaiter;

    // --- METODA MOVENEXT (serce logiki) ---
    public void MoveNext()
    {
        string finalResult = null;

        try
        {
            if (state == -1)                             // ETAP 0: kod przed pierwszym await
            {
                Console.WriteLine("Start");
                _result = "Data";

                // Bierzemy "awaitera" z Task.Delay
                _awaiter = Task.Delay(1000).GetAwaiter();

                if (!_awaiter.IsCompleted)
                {
                    // Task NIE skończony - parkujemy maszynę
                    state = 0;
                    builder.AwaitUnsafeOnCompleted(ref _awaiter, ref this);
                    return;                              // wątek wraca do puli!
                }
            }

            if (state == 0)                              // ETAP 1: kod po pierwszym await
            {
                _awaiter.GetResult();                    // rzuci wyjątek, jeśli task zfaultował

                _result += " processed";
                finalResult = _result;
            }
        }
        catch (Exception ex)
        {
            state = -2;
            builder.SetException(ex);                    // zgłoś błąd przez Task
            return;
        }

        // Zakończenie sukcesem
        state = -2;
        builder.SetResult(finalResult);                  // wypełnij Task wynikiem
    }

    public void SetStateMachine(IAsyncStateMachine sm) { }
}

Dziewięć linii Twojego kodu staje się ~50 linii wygenerowanego IL. Kompilator pracuje tak za każdym razem, gdy zobaczy async.

4. Anatomia IAsyncStateMachine #

Rozłóżmy strukturę na elementy.

Dlaczego struct, a nie class? #

Wydajność. Struktura jest typem wartościowym - alokowana na stosie. Jeśli operacja await zakończy się synchronicznie (np. Task.IsCompleted == true, bo wynik był już dostępny w cache), kod leci dalej bez alokacji na stercie i bez obciążania GC.

Alokacja na stercie zachodzi dopiero gdy await faktycznie musi czekać - wtedy struktura jest boxowana w specjalny obiekt (MoveNextRunner lub podobny), do którego scheduler ma trwałą referencję. To kosztuje, ale dzieje się tylko gdy potrzeba - nie przy każdym await.

W praktyce: większość await-ów w produkcyjnym kodzie nie musi rzeczywiście czekać (np. odpytujesz cache, połączenie HTTP/2 ze zwolnionym slotem, lokalny dysk). Maszyna stanów jako struct eksploruje ten szybki przypadek - z minimalnym kosztem.

Pole state - licznik etapów #

To ostatnie miejsce, w którym maszyna była przed parkowaniem. Konwencja:

Wartość stateCo znaczy
-1Nie zaczęte (przed pierwszym MoveNext)
0, 1, 2, …Sparkowane w n-tym await
-2Zakończone (sukces lub błąd)

Wewnątrz MoveNext switch(state) (lub if-y) skacze do odpowiedniego miejsca - dzięki temu kontynuacja zaczyna się dokładnie tam, gdzie była pauza.

Pole builder - AsyncTaskMethodBuilder<T> #

Most między wewnętrznym stanem maszyny a zewnętrznym Task<T>, który widzi wywołujący. Kluczowe operacje buildera:

MetodaCo robi
Create()tworzy nowy builder
Start(ref machine)inicjuje maszynę - pierwsze MoveNext
AwaitUnsafeOnCompleted(ref awaiter, ref machine)przypina maszynę do oczekiwanego task-a
SetResult(value)kończy Task z wartością
SetException(ex)kończy Task z błędem
Task (właściwość)zwraca Task<T> do zwrócenia z metody

Zmienne lokalne → pola struktury #

Najważniejsza transformacja. Twoja zmienna result w oryginalnej metodzie była lokalna - żyła na stosie wątku. Gdyby wątek opuścił metodę przy await, stos zostałby zwolniony - zmienna zniknęłaby.

Kompilator obchodzi to przenosząc każdą zmienną lokalną, która jest używana po await, do pola struktury. Pole struktury żyje tak długo, jak żyje sama struktura (a struktura żyje aż maszyna stanów się nie zakończy). Pauza, opuszczenie metody, godzina czekania - wszystko jedno, _result siedzi w polu i czeka na wznowienie.

To wyjaśnia też, czemu nie wszystkie zmienne lokalne są przenoszone - tylko te używane po await. Te użyte tylko przed pierwszym await zostają na stosie - tańsze.

Metoda MoveNext - serce #

Wszystko sprowadza się do MoveNext. Cykl działania:

  1. Wywołanie 1: builder.Start woła MoveNext z state = -1. Wykonuje się kod od początku do pierwszego await.
  2. Jeśli oczekiwany task już skończony (IsCompleted == true) - kontynuujemy synchronicznie.
  3. Jeśli nie - zapisujemy state = 0, wywołujemy AwaitUnsafeOnCompleted i wracamy z metody. Wątek wolny.
  4. Gdy task się kończy, scheduler ponownie woła MoveNext (potencjalnie na innym wątku).
  5. MoveNext widzi state == 0, skacze do kodu po pierwszym await, wykonuje dalej.
  6. Cykl powtarza się dla każdego await. Ostatnia ścieżka kończy się builder.SetResult(finalValue).

builder.AwaitUnsafeOnCompleted - magiczny hook #

Ta jedna linia łączy świat awaiterów ze światem maszyn stanów. W skrócie wykonuje:

  1. Bierze awaiter (otrzymany z task-a, którego czekamy).
  2. Boxuje maszynę stanów na stercie (jeśli to pierwsze zaparkowanie).
  3. Rejestruje kontynuację w awaiterze: gdy się skończysz, wywołaj MoveNext na tej maszynie.
  4. Wraca z MoveNext - sterowanie wraca do wywołującego (i wątek zostaje uwolniony).

Gdy task się skończy, jego awaiter sam wywoła zaplanowaną kontynuację - czyli MoveNext z nowym state. Cykl trwa.

5. Co ta wiedza daje w praktyce #

Dwa konkretne, namacalne zyski.

Rozumienie stack trace’a wyjątku w async #

Wyjątek w metodzie async daje stack trace, który wygląda mniej więcej tak:

System.InvalidOperationException: Coś się zepsuło
   at MyApp.WeatherService.<GetWeatherAsync>d__3.MoveNext()
   at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.Start[TStateMachine]
   at MyApp.WeatherService.GetWeatherAsync(String city)
   at MyApp.Program.<Main>d__0.MoveNext()

Dziwne <GetWeatherAsync>d__3 to wygenerowana struktura maszyny stanów dla metody GetWeatherAsync. MoveNext() to jedyna metoda tej struktury, w której wykonuje się Twój kod. Wpisy AsyncTaskMethodBuilder.Start to “infrastruktura” - można je przewinąć i szukać miejsca w swoim kodzie.

Bez zrozumienia maszyny stanów te wpisy wyglądają jak szum. Z wiedzą - łatwiej znaleźć źródło problemu.

Optymalizacja “passthrough” #

Częsty wzorzec - metoda async, która tylko przekazuje wynik innej metody async:

// Generuje pełną maszynę stanów (await jest w metodzie)
public async Task<string> GetWeatherAsync(string city)
{
    return await _service.FetchAsync(city);
}

Można to zoptymalizować, usuwając async/await i zwracając Task bezpośrednio:

// Brak maszyny stanów - po prostu zwraca Task wewnętrznej metody
public Task<string> GetWeatherAsync(string city)
{
    return _service.FetchAsync(city);
}

Funkcjonalnie identyczne. Wewnętrznie - wersja druga nie generuje maszyny stanów, oszczędza alokacje i kilka wywołań metod. Dla “gorącej” ścieżki przelotowej (np. metoda kontrolera tylko delegująca do serwisu) to mierzalna oszczędność.

Uwaga: nie zawsze działa. Jeśli między wywołaniem a returnem masz try/catch, using, lub logikę po await - musisz zostawić await, bo to zmienia semantykę propagacji wyjątków i Dispose.

Wnioskowanie o wydajności #

Każda metoda async z await to:

  • Wygenerowana struktura.
  • Boxowanie na stertę przy faktycznym czekaniu.
  • Wywołania MoveNext przez scheduler.
  • Alokacja Task<T> jako zwracana wartość.

Pojedyncze użycie jest niezauważalne. W gorących pętlach z milionami await na cacheowane wyniki - sumuje się. W takich przypadkach ValueTask<T> (zamiast Task<T>) plus świadomość maszyny stanów pomaga eliminować narzut.

Podsumowanie tematu #

W tej części kursu poznałeś:

  • Iluzja async/await - to wyłącznie słowa kluczowe kompilatora; CLR nie zna pojęcia “await”, widzi tylko strukturę z MoveNext i wywołania AsyncTaskMethodBuilder
  • Maszyna stanów - struktura implementująca IAsyncStateMachine, generowana przez Roslyn za każdą metodą async
  • Zmienne lokalne → pola - zmienne używane po await są przenoszone do pól struktury, żeby przeżyły pauzy; to fundamentalne wyjaśnienie jak metoda “pamięta” stan między awaitami
  • struct, nie class - optymalizacja: brak alokacji na stercie, jeśli await zakończy się synchronicznie; boxing tylko gdy faktyczna pauza
  • Pole state - licznik etapów (-1 start, 0/1/2… punkty po await, -2 koniec); MoveNext używa go do skoku do bieżącego etapu
  • AsyncTaskMethodBuilder<T> - kontroler Task widocznego dla wywołującego; SetResult/SetException aktywują obietnicę
  • MoveNext - serce logiki, wywoływane raz na start i każdy raz, gdy oczekiwany task się skończy
  • AwaitUnsafeOnCompleted - hook łączący awaiter z maszyną; “obudź mnie, gdy task skończy”
  • Wnioski praktyczne - stack trace’y z MoveNext to wnętrza maszyny; optymalizacja passthrough przez usunięcie zbędnego async/await; świadomość kosztu

W następnym wpisie Task kontra ValueTask w C# zobaczysz, kiedy alokacja zwykłego Task zaczyna obciążać GC, jak ValueTask jako struktura eliminuje alokacje w hot path z cache, i dlaczego ma cztery żelazne zasady bezpieczeństwa, których złamanie kończy się niezdefiniowanym zachowaniem.

Quiz i zadanie poniżej. Zadanie demonstruje najważniejszą rzecz o maszynie stanów: zmienne lokalne, które przechodzą przez trzy await-y i akumulują wartości. Deterministyczny wynik 25 to dowód transformacji - bez maszyny stanów value zostałaby utracona przy pierwszej pauzie.

Podobne wpisy

🔗 Linkują tu