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:
- Tworzy obiekt struktury (typu, który Ty nigdy nie napisałeś).
- Wywołuje na nim metodę
MoveNext(). - Czeka na “powiadomienie” (notification) z innego zadania.
- 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ść state | Co znaczy |
|---|---|
-1 | Nie zaczęte (przed pierwszym MoveNext) |
0, 1, 2, … | Sparkowane w n-tym await |
-2 | Zakoń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:
| Metoda | Co 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:
- Wywołanie 1:
builder.StartwołaMoveNextzstate = -1. Wykonuje się kod od początku do pierwszegoawait. - Jeśli oczekiwany task już skończony (
IsCompleted == true) - kontynuujemy synchronicznie. - Jeśli nie - zapisujemy
state = 0, wywołujemyAwaitUnsafeOnCompletedi wracamy z metody. Wątek wolny. - Gdy task się kończy, scheduler ponownie woła
MoveNext(potencjalnie na innym wątku). MoveNextwidzistate == 0, skacze do kodu po pierwszymawait, wykonuje dalej.- 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:
- Bierze awaiter (otrzymany z task-a, którego czekamy).
- Boxuje maszynę stanów na stercie (jeśli to pierwsze zaparkowanie).
- Rejestruje kontynuację w awaiterze: gdy się skończysz, wywołaj
MoveNextna tej maszynie. - 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
MoveNextprzez 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ę zMoveNexti wywołaniaAsyncTaskMethodBuilder - Maszyna stanów - struktura implementująca
IAsyncStateMachine, generowana przez Roslyn za każdą metodąasync - Zmienne lokalne → pola - zmienne używane po
awaitsą przenoszone do pól struktury, żeby przeżyły pauzy; to fundamentalne wyjaśnienie jak metoda “pamięta” stan między awaitami struct, nieclass- optymalizacja: brak alokacji na stercie, jeśli await zakończy się synchronicznie; boxing tylko gdy faktyczna pauza- Pole
state- licznik etapów (-1start,0/1/2… punkty po await,-2koniec);MoveNextużywa go do skoku do bieżącego etapu AsyncTaskMethodBuilder<T>- kontrolerTaskwidocznego dla wywołującego;SetResult/SetExceptionaktywują obietnicęMoveNext- serce logiki, wywoływane raz na start i każdy raz, gdy oczekiwany task się skończyAwaitUnsafeOnCompleted- hook łączący awaiter z maszyną; “obudź mnie, gdy task skończy”- Wnioski praktyczne - stack trace’y z
MoveNextto wnętrza maszyny; optymalizacja passthrough przez usunięcie zbędnegoasync/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
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