Wszystkie wpisy

Task kontra ValueTask w C# - kiedy struct zamiast referencji oszczędza GC

Optymalizacja asynchroniczności dla hot path - ValueTask jako struct unika alokacji Task na stercie, gdy operacja kończy się synchronicznie. Cztery żelazne zasady bezpieczeństwa, trade-offy, kiedy używać a kiedy stać przy zwykłym Task. Czternasta część kursu C#.


W poprzednim wpisie Maszyna stanów async w C# zobaczyłeś, że kompilator generuje całą strukturę za każdą metodą async, plus alokuje Task<T> jako wartość zwracaną. Pojedyncze użycie jest niezauważalne. Ale gdy ta sama metoda jest wywoływana 10 000 razy na sekundę w serwisie HTTP pod obciążeniem - alokacje Task zaczynają zapychać Garbage Collector. Każdy Task to obiekt na stercie. Każda alokacja przybliża następny przebieg GC. To rzeczywisty problem produkcyjny, którego rozwiązaniem jest ValueTask<T>.

W tej części zobaczysz, kiedy ValueTask daje wymierne korzyści, dlaczego ma cztery żelazne zasady bezpieczeństwa (których złamanie kończy się niezdefiniowanym zachowaniem) oraz dlaczego sam Microsoft odradza zamianę wszystkich Task na ValueTask - to wiedza pytana często na rozmowach kwalifikacyjnych Mid/Senior.

1. Problem: cena zwracania Task<T> #

Task i Task<T>klasami - typami referencyjnymi. Każde wywołanie metody async zwracającej Task<T> to alokacja nowego obiektu na stercie.

// Wywoływana 10 000 razy/sekundę w controllerze ASP.NET Core
public async Task<UserConfig> LoadConfigAsync(int userId)
{
    if (_cache.TryGetValue(userId, out var cached))
        return cached;                       // alokacja Task<UserConfig>!
    return await _db.LoadAsync(userId);      // też alokacja
}

Konsekwencje:

  • 10 000 nowych obiektów na stercie co sekundę = silna presja na GC (GC pressure).
  • GC działa częściej i dłużej - mikro-przycięcia aplikacji.
  • W “ogólnej kuchni” niezauważalne. W systemach wysokiego obciążenia (gateway API, serwery gier, brokery wiadomości) - widoczne na wykresach percentyli czasów odpowiedzi.

Ironiczne: gdy cached jest dostępne, sama operacja jest synchroniczna - dane są od ręki. Mimo to i tak alokujemy Task<UserConfig>, by zgodzić się z sygnaturą metody async. To zbędna alokacja, której nie unikniemy zwykłym Task.

2. Wejście ValueTask - struktura, nie referencja #

Cytat z dokumentacji Microsoftu: Provides a value type that wraps a Task and a TResult, only one of which is used.

ValueTask<T> to struktura z dwoma stanami:

StanCo zawieraGdzie żyje
Sukces synchronicznyBezpośredni wynik T (np. string, int)Stos - bez alokacji na stercie
AsynchronicznyOwinięty zwykły Task<T>Sterta (jak normalny Task)

W praktyce:

public async ValueTask<string> GetUserNameAsync(int id)
{
    // ŚCIEŻKA SZYBKA - dane z cache, brak await
    if (_cache.TryGetValue(id, out string cached))
    {
        return cached;        // ValueTask<string> z gotowym wynikiem
                              // ŻADNEJ alokacji Task<string> na stercie
    }

    // ŚCIEŻKA WOLNA - musimy faktycznie poczekać
    string name = await _db.LoadAsync(id);    // tu alokacja Task zachodzi
    _cache[id] = name;
    return name;
}

W scenariuszu cache: 95% wywołań to pierwsza ścieżka. ValueTask w tej ścieżce ma zero alokacji - cała struktura żyje na stosie wywołującej metody. Pozostałe 5% trafia na drugą ścieżkę i alokuje Task jak zawsze.

Optymalizacja w liczbach #

SytuacjaZ Task<T>Z ValueTask<T>
10 000 wywołań/s, 95% cache hit10 000 alokacji Task na stercie500 alokacji Task (tylko miss)
Obciążenie GCPełne-95% w gorącej ścieżce

To bezpośredni przekład na lepsze percentyle czasu odpowiedzi pod obciążeniem.

default(ValueTask<T>) - synchroniczny szybki zwrot #

Specjalna konwencja: default(ValueTask<T>) (lub po prostu default jeśli kompilator wnioskuje typ) to synchronicznie pomyślnie zakończona operacja z wynikiem default(T). Nie ma alokacji, nie ma Task. Praktyczny skrót:

public ValueTask<int> GetCountAsync()
{
    if (_count == 0) return default;       // sync, brak alokacji, zwraca 0
    return new ValueTask<int>(_count);     // sync z konkretnym wynikiem
}

3. Cztery żelazne zasady bezpieczeństwa #

Dokumentacja Microsoftu wprost wymienia cztery operacje, których NIGDY nie wolno wykonać na ValueTask<T>:

  1. await więcej niż raz na tej samej instancji
  2. AsTask() więcej niż raz na tej samej instancji
  3. .Result lub .GetAwaiter().GetResult() przed zakończeniem operacji albo wielokrotnie
  4. Mieszanie powyższych technik na jednej instancji

Cytat MS: If you do any of the above, the results are undefined.

Czemu te zasady istnieją #

ValueTask z biblioteki standardowej często używa puli IValueTaskSource<T> (recyklingowanego źródła) - by zaoszczędzić alokacje również przy ścieżce asynchronicznej. Po pierwszym await/AsTask instance “źródła” wraca do puli - następne wywołanie może je dostać dla innej operacji. Powtórny await na starym ValueTask operuje na zrecyklingowanym slocie z cudzą wartością. Niedeterministyczne dane, błędy, crashe.

Anti-pattern w kodzie #

ValueTask<int> vt = service.GetCountAsync();
int a = await vt;            // OK - pierwszy await
int b = await vt;            // ❌ KATASTROFA - niezdefiniowane zachowanie
ValueTask<int> vt = service.GetCountAsync();
Task<int> t1 = vt.AsTask();  // OK - jeden AsTask
Task<int> t2 = vt.AsTask();  // ❌ Drugi AsTask na tej samej instancji
ValueTask<int> vt = service.GetCountAsync();
int a = await vt;            // OK
int b = vt.Result;           // ❌ Mieszanie await + Result

Wyjście awaryjne: Preserve() #

Gdy musisz użyć ValueTask wielokrotnie (np. przekazać do Task.WhenAll albo zapisać na później), zamiast łamać zasady wywołaj vt.Preserve():

ValueTask<int> vt = service.GetCountAsync();
ValueTask<int> preserved = vt.Preserve();   // można używać wielokrotnie
int a = await preserved;
int b = await preserved;                    // OK

Preserve() pod spodem zwykle wywołuje AsTask() - alokuje Task - ale w jawny, kontrolowany sposób. Wiesz, że tracisz korzyść ValueTask, i akceptujesz to świadomie.

4. Trade-offs - czemu ValueTask to nie złoty środek #

Z pozoru ValueTask jest “lepszy” - czemu nie używać go wszędzie? Bo struktura ma własne koszty.

Cytat MS - dosłownie: a ValueTask contains multiple fields, whereas a Task as a reference type is a single field. This means that returning a ValueTask from a method results in copying more data. It also means, that if a method that returns a ValueTask is awaited within an async method, the state machine for that async method will be larger, because it must store a struct containing multiple fields instead of a single reference.

Praktyczne implikacje:

AspektTask<T>ValueTask<T>
Rozmiarjedna referencja (8 bajtów na x64)wiele pól (zwykle 16-24 bajty)
Przekazanie do metodyszybkie - kopia referencjiwolniejsze - kopia całej struktury
Wewnątrz maszyny stanówjedna referencja w polucała struktura w polu - maszyna większa
Task.WhenAll/WhenAnynatywnie wspieranywymaga AsTask() (alokacja - traci sens!)

Wniosek: dla metod, które zawsze są asynchroniczne (zawsze idą na bazę, zawsze idą na sieć), Task<T> jest tak samo dobry, a często lepszy - mniej kopiowania, mniejsze maszyny stanów. ValueTask opłaca się tylko gdy synchroniczna ścieżka jest realnie dominująca.

5. ValueTask vs Task - tabela decyzyjna #

SytuacjaWybór
Zwykła metoda biznesowa wywoływana raz na żądanieTask<T>
Metoda zawsze robi I/O (sieć/baza/dysk)Task<T>
Metoda z cache, gdzie ~90%+ to trafienia (hot path)ValueTask<T> (po profilowaniu!)
Metoda przekazywana do Task.WhenAll/WhenAnyTask<T> (ValueTask wymagałby AsTask())
Metoda używana wielokrotnie (kilka await na jednej obietnicy)Task<T>
Metoda asynchroniczna w bibliotece publicznejTask<T> (mniej pułapek dla użytkownika)
Niegenerska wersja (zamiast Task)Task - cytat MS: not recommended for most scenarios

6. Reguła kciuka - oficjalna rekomendacja MS #

Cytat z dokumentacji MS, dosłownie:

the default choice for any asynchronous method should be to return a Task or Task. Only if performance analysis proves it worthwhile should a ValueTask be used instead of a Task.

Algorytm decyzyjny:

  1. Domyślnie - Task<T>. Zawsze.
  2. Czy metoda jest w gorącej ścieżce (wywoływana setki/tysiące razy na sekundę)? Jeśli nie → krok 1.
  3. Czy metoda często zwraca synchronicznie (cache hit, lokalny stan, walidacja “od ręki”)? Jeśli nie → krok 1.
  4. Czy profiler pamięci (dotMemory, Visual Studio Diagnostic Tools, PerfView) udowodnił, że alokacje Task w tym miejscu zapychają GC? Jeśli nie → krok 1.
  5. Tylko jeśli na wszystkie pytania odpowiedź to “tak” → rozważ ValueTask<T>.

Bez pomiaru - Task. Z pomiarem dowodzącym problem - ValueTask w tym konkretnym miejscu.

Podsumowanie tematu #

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

  • Problem alokacji Task - typ referencyjny, każde wywołanie metody async alokuje obiekt na stercie → GC pressure w hot path (np. 10 000 wywołań/s)
  • ValueTask<T> jako struktura - dwa stany (synchroniczny wynik T bez alokacji albo opakowany Task w asynchronicznym przypadku); zero alokacji w szybkiej ścieżce
  • Optymalne zastosowanie - hot path plus dominujący przypadek synchroniczny (cache hit, lokalna walidacja); cytat MS o “likely synchronously available result” + “frequently invoked”
  • Cztery żelazne zasady - nigdy: wielokrotny await, wielokrotny AsTask(), Result/GetResult przed ukończeniem lub wielokrotnie, mieszanie technik. Konsekwencja: niezdefiniowane zachowanie
  • Preserve() - kontrolowana konwersja do wielokrotnego użytku (kosztem alokacji)
  • Trade-offs - struktura większa niż referencja, kopiowanie droższe, maszyny stanów awaitujące ValueTask są większe
  • Niezgodność z Task.WhenAll/WhenAny - wymaga AsTask() (alokacja - traci całą korzyść)
  • Reguła kciuka MS - domyślnie zawsze Task<T>; ValueTask tylko gdy profiler udowodnił problem GC w konkretnym miejscu

W następnym wpisie Zaawansowany async w C# zobaczysz trzy narzędzia używane przez senior developerów i architektów: ConfigureAwait(false) (uwalnianie wątku z kontekstu), CancellationToken (kooperatywne anulowanie) oraz IAsyncEnumerable<T> (asynchroniczne strumienie z await foreach).

Quiz i zadanie poniżej. Zadanie demonstruje kanoniczny scenariusz dla ValueTask: usługa z cache, gdzie pierwsze dwa wywołania to miss (asynchroniczne, alokacja Task), a trzecie - hit (synchroniczne, bez alokacji). Mimo tej różnicy konsumujący kod widzi dokładnie ten sam interfejs await.

Podobne wpisy

🔗 Linkują tu