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> są 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
ValueTask<T> to struktura z dwoma stanami:
| Stan | Co zawiera | Gdzie żyje |
|---|---|---|
| Sukces synchroniczny | Bezpośredni wynik T (np. string, int) | Stos - bez alokacji na stercie |
| Asynchroniczny | Owinię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 #
| Sytuacja | Z Task<T> | Z ValueTask<T> |
|---|---|---|
| 10 000 wywołań/s, 95% cache hit | 10 000 alokacji Task na stercie | 500 alokacji Task (tylko miss) |
| Obciążenie GC | Peł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>:
awaitwięcej niż raz na tej samej instancjiAsTask()więcej niż raz na tej samej instancji.Resultlub.GetAwaiter().GetResult()przed zakończeniem operacji albo wielokrotnie- 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
Praktyczne implikacje:
| Aspekt | Task<T> | ValueTask<T> |
|---|---|---|
| Rozmiar | jedna referencja (8 bajtów na x64) | wiele pól (zwykle 16-24 bajty) |
| Przekazanie do metody | szybkie - kopia referencji | wolniejsze - kopia całej struktury |
| Wewnątrz maszyny stanów | jedna referencja w polu | cała struktura w polu - maszyna większa |
Task.WhenAll/WhenAny | natywnie wspierany | wymaga 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 #
| Sytuacja | Wybór |
|---|---|
| Zwykła metoda biznesowa wywoływana raz na żądanie | Task<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/WhenAny | Task<T> (ValueTask wymagałby AsTask()) |
Metoda używana wielokrotnie (kilka await na jednej obietnicy) | Task<T> |
| Metoda asynchroniczna w bibliotece publicznej | Task<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:
- Domyślnie -
Task<T>. Zawsze. - Czy metoda jest w gorącej ścieżce (wywoływana setki/tysiące razy na sekundę)? Jeśli nie → krok 1.
- Czy metoda często zwraca synchronicznie (cache hit, lokalny stan, walidacja “od ręki”)? Jeśli nie → krok 1.
- Czy profiler pamięci (dotMemory, Visual Studio Diagnostic Tools, PerfView) udowodnił, że alokacje
Taskw tym miejscu zapychają GC? Jeśli nie → krok 1. - 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 wynikTbez 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, wielokrotnyAsTask(),Result/GetResultprzed 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
ValueTasksą większe - Niezgodność z
Task.WhenAll/WhenAny- wymagaAsTask()(alokacja - traci całą korzyść) - Reguła kciuka MS - domyślnie zawsze
Task<T>;ValueTasktylko 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
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#.
ArrayPool i MemoryPool w C# - wypożyczalnia tablic kontra LOH
Recykling dużych buforów dla hot path - LOH (Large Object Heap) i jego pułapki, ArrayPool<T>.Shared z Rent/Return, pułapka nadmiarowego rozmiaru, MemoryPool<T> z IMemoryOwner i using dla async. Dwudziesta pierwsza część kursu C#.
Parsowanie tekstu bez alokacji - Zero-Allocation Log Parser w C#
Praktyczne zastosowanie Span<char>, ReadOnlySpan<char> i ref struct - parsowanie linii logu serwera bez ani jednej alokacji na stercie. IndexOf, Slice, SequenceEqual, int.Parse i DateTime.Parse z przeciążeniami dla Span. Dwudziesta część kursu C#.
🔗 Linkują tu