Dlaczego należy używać Task <T> zamiast ValueTask <T> w C #?

168

Od C # 7,0 metody asynchroniczne mogą zwracać ValueTask <T>. Wyjaśnienie mówi, że należy go używać, gdy mamy buforowany wynik lub symulujemy asynchroniczność za pomocą kodu synchronicznego. Jednak nadal nie rozumiem, jaki jest problem z używaniem ValueTask zawsze lub w rzeczywistości, dlaczego async / await nie zostało zbudowane z typem wartości od początku. Kiedy ValueTask nie wykona zadania?

Stilgar
źródło
7
Podejrzewam, że ma to związek z korzyściami ValueTask<T>(pod względem alokacji) niezrealizowaniem operacji, które są faktycznie asynchroniczne (ponieważ w takim przypadku ValueTask<T>nadal będzie potrzebować alokacji sterty). Jest też kwestia Task<T>posiadania innego wsparcia w bibliotekach.
Jon Skeet
3
@JonSkeet istniejące biblioteki są problemem, ale nasuwa się pytanie, czy zadanie powinno być od początku ValueTask? Korzyści mogą nie istnieć, gdy używasz go do rzeczywistych rzeczy asynchronicznych, ale czy jest to szkodliwe?
Stilgar
8
Zobacz github.com/dotnet/corefx/issues/4708#issuecomment-160658188 więcej mądrości niż byłbym w stanie przekazać :)
Jon Skeet
3
@JoelMueller fabuła gęstnieje :)
Stilgar

Odpowiedzi:

245

Z dokumentacji API (podkreślenie dodane):

Metody mogą zwracać wystąpienie tego typu wartości, gdy jest prawdopodobne, że wynik ich operacji będzie dostępny synchronicznie i gdy metoda ma być wywoływana tak często, że koszt przydzielenia nowego Task<TResult>dla każdego wywołania będzie wygórowany.

Istnieją kompromisy związane z używaniem a ValueTask<TResult>zamiast a Task<TResult>. Na przykład, chociaż ValueTask<TResult>może pomóc uniknąć alokacji w przypadku, gdy pomyślny wynik jest dostępny synchronicznie, zawiera również dwa pola, podczas Task<TResult>gdy typ odniesienia jest pojedynczym polem. Oznacza to, że wywołanie metody kończy się zwróceniem danych o wartości dwóch pól zamiast jednego, co oznacza więcej danych do skopiowania. Oznacza to również, że jeśli metoda, która zwraca jeden z nich, jest oczekiwana w asyncmetodzie, maszyna stanu dla tej asyncmetody będzie większa z powodu konieczności przechowywania struktury składającej się z dwóch pól zamiast pojedynczego odwołania.

Ponadto, do celów innych niż spożywanie wynik asynchronicznej pracy poprzez await, ValueTask<TResult>może prowadzić do bardziej zawiłe modelu programowania, które z kolei mogą rzeczywiście prowadzić do większej liczby przydziałów. Na przykład rozważmy metodę, która może zwrócić Task<TResult>jako typowy wynik zadanie z buforowanym zadaniem lub plik ValueTask<TResult>. Jeżeli konsument wyniku chce wykorzystać go jako Task<TResult>, na przykład do stosowania z metod, jak Task.WhenAlli Task.WhenAnyThe ValueTask<TResult>najpierw muszą zostać przekształcone w Task<TResult>użyciu AsTask, co prowadzi do przyznania, że było uniknąć, jeśli w pamięci podręcznej Task<TResult>zostały wykorzystane na pierwszym miejscu.

W związku z tym domyślnym wyborem dla każdej metody asynchronicznej powinno być zwrócenie Tasklub Task<TResult>. Tylko wtedy, gdy analiza wydajności wykaże, że warto ValueTask<TResult>ją zastosować, a nie Task<TResult>.

Stephen Cleary
źródło
7
@MattThomas: Oszczędza pojedynczą Taskalokację (która jest obecnie niewielka i tania), ale kosztem zwiększenia istniejącej alokacji wywołującego i podwojenia wielkości zwracanej wartości (wpływającej na alokację rejestrów). Chociaż jest to oczywisty wybór dla scenariusza odczytu buforowanego, stosowanie go domyślnie do wszystkich interfejsów nie jest czymś, co polecam.
Stephen Cleary
1
W prawo, albo Tasklub ValueTaskmoże być używany jako synchroniczny typ powrotu (z Task.FromResult). Ale jest jeszcze wartość (heh) w ValueTaskjeśli masz coś, czego oczekiwać , aby być synchroniczne. ReadByteAsyncjest klasycznym przykładem. Wydaje mi się, żeValueTask został stworzony głównie dla nowych „kanałów” (strumieni bajtów niskiego poziomu), prawdopodobnie używanych również w rdzeniu ASP.NET, gdzie wydajność naprawdę ma znaczenie.
Stephen Cleary
1
Och, wiem, lol, zastanawiałem się tylko, czy masz coś do dodania do tego konkretnego komentarza;)
julealgon
2
Czy ten PR zmienia równowagę na preferowanie ValueTask? (ref: blog.marcgravell.com/2019/08/… )
stuartd
2
@stuartd: Na razie nadal zalecałbym używanie Task<T>jako domyślne. Dzieje się tak tylko dlatego, że większość deweloperów nie jest zaznajomionych z ograniczeniami ValueTask<T>(w szczególności z regułą „konsumuj tylko raz” i zasadą „bez blokowania”). To powiedziawszy, jeśli wszyscy deweloperzy w twoim zespole są dobrzy ValueTask<T>, to poleciłbym wytyczne dotyczące preferowania na poziomie zespołu ValueTask<T>.
Stephen Cleary
104

Jednak nadal nie rozumiem, na czym polega problem z korzystaniem zawsze z ValueTask

Typy struktur nie są darmowe. Kopiowanie struktur, które są większe niż rozmiar odwołania, może być wolniejsze niż kopiowanie odwołania. Przechowywanie struktur, które są większe niż odwołanie, zajmuje więcej pamięci niż przechowywanie odwołania. Struktury, które są większe niż 64 bity, mogą nie zostać zarejestrowane, gdy można zarejestrować odniesienie. Korzyści wynikające z niższego ciśnienia odbioru nie mogą przewyższać kosztów.

Do problemów z wydajnością należy podchodzić z dyscypliną inżynierską. Stawiaj cele, mierz swoje postępy w stosunku do celów, a następnie zdecyduj, jak zmodyfikować program, jeśli cele nie zostaną osiągnięte, mierząc po drodze, aby upewnić się, że zmiany są rzeczywiście ulepszeniami.

dlaczego async / await nie zostało zbudowane z typem wartości od początku.

awaitzostał dodany do C # długo po tym, jak Task<T>typ już istniał. Byłoby nieco perwersyjne wymyślić nowy typ, który już istniał. I awaitprzeszedł przez wiele iteracji projektowych, zanim zdecydował się na ten, który został wysłany w 2012 roku. Doskonałość jest wrogiem dobrego; Lepiej jest dostarczyć rozwiązanie, które dobrze współpracuje z istniejącą infrastrukturą, a następnie, jeśli istnieje zapotrzebowanie użytkowników, wprowadzić ulepszenia później.

Zwracam również uwagę, że nowa funkcja polegająca na umożliwieniu typom dostarczonym przez użytkownika, aby były wynikiem metody wygenerowanej przez kompilator, zwiększa znaczne ryzyko i obciążenie testowaniem. Kiedy jedyne, co możesz zwrócić, to nieważne lub zadanie, zespół testujący nie musi rozważać żadnego scenariusza, w którym zwracany jest jakiś absolutnie szalony typ. Testowanie kompilatora oznacza ustalenie nie tylko, jakie programy ludzie prawdopodobnie będą pisać, ale jakie programy można napisać, ponieważ chcemy, aby kompilator skompilował wszystkie legalne programy, a nie tylko wszystkie rozsądne programy. To jest drogie.

Czy ktoś może wyjaśnić, kiedy ValueTask nie wykonałby zadania?

Celem tego jest poprawa wydajności. Nie ma wykonać zadanie, jeśli nie wymiernie i znacznie poprawić wydajność. Nie ma żadnej gwarancji, że tak się stanie.

Eric Lippert
źródło
23

ValueTask<T>nie jest podzbiorem Task<T>, jest nadzbiorem .

ValueTask<T>jest rozłączną sumą T i a Task<T>, dzięki czemu jest wolny od alokacji, ReadAsync<T>aby synchronicznie zwracać dostępną wartość T (w przeciwieństwie do używania Task.FromResult<T>, które wymaga przydzielenia Task<T>instancji). ValueTask<T>jest oczekiwana, więc większość użycia instancji będzie nie do odróżnienia od pliku Task<T>.

ValueTask, będąc strukturą, umożliwia pisanie metod asynchronicznych, które nie przydzielają pamięci, gdy działają synchronicznie, bez narażania spójności interfejsu API. Wyobraź sobie interfejs z metodą zwracającą Task. Każda klasa implementująca ten interfejs musi zwracać Task, nawet jeśli zdarza się, że są wykonywane synchronicznie (miejmy nadzieję, że używają Task.FromResult). Możesz oczywiście mieć 2 różne metody na interfejsie, synchroniczną i asynchroniczną, ale wymaga to 2 różnych implementacji, aby uniknąć „synchronizacji przez asynchronizację” i „asynchronizacji przez synchronizację”.

Pozwala więc napisać jedną metodę, która jest asynchroniczna lub synchroniczna, zamiast pisać po jednej identycznej metodzie dla każdej. Możesz go użyć w dowolnym miejscu, Task<T>ale często nie będzie to nic dodawać.

Cóż, dodaje jedną rzecz: dodaje do wywołującego dorozumianą obietnicę, że metoda faktycznie korzysta z dodatkowej funkcjonalności, która ValueTask<T>zapewnia. Osobiście wolę wybierać parametry i typy zwracane, które mówią dzwoniącemu jak najwięcej. Nie zwracaj, IList<T>jeśli wyliczenie nie może zapewnić liczby; nie wracaj, IEnumerable<T>jeśli to możliwe. Twoi klienci nie powinni musieć szukać dokumentacji, aby wiedzieć, które z metod można rozsądnie wywołać synchronicznie, a które nie.

Nie widzę tam przyszłych zmian konstrukcyjnych jako przekonującego argumentu. Wręcz przeciwnie: jeśli metoda zmieni swoją semantykę, powinna przerwać kompilację, dopóki wszystkie jej wywołania nie zostaną odpowiednio zaktualizowane. Jeśli uznasz to za niepożądane (i uwierz mi, sympatyzuję z chęcią nie zepsucia kompilacji), rozważ wersjonowanie interfejsu.

Zasadniczo do tego służy silne pisanie.

Jeśli niektórzy programiści projektujący metody asynchroniczne w Twoim sklepie nie są w stanie podjąć świadomych decyzji, pomocne może być przydzielenie starszego mentora każdemu z tych mniej doświadczonych programistów i cotygodniowy przegląd kodu. Jeśli źle odgadną, wyjaśnij, dlaczego należy to zrobić inaczej. Dla starszych facetów jest to koszt ogólny, ale dzięki temu juniorzy znacznie szybciej będą szybciej niż po prostu rzucić ich na głęboką wodę i dać im jakąś arbitralną zasadę do naśladowania.

Jeśli facet, który napisał tę metodę, nie wie, czy można ją wywołać synchronicznie, kto to robi ?!

Jeśli masz tylu niedoświadczonych programistów piszących metody asynchroniczne, czy ci sami ludzie też je nazywają? Czy mają kwalifikacje do samodzielnego ustalenia, które z nich można bezpiecznie wywołać jako asynchroniczne, czy też zaczną stosować podobnie arbitralną regułę do tego, jak nazywają te rzeczy?

Problemem nie są tutaj typy zwrotów, tylko programiści odgrywający role, na które nie są gotowi. To musiało się wydarzyć z jakiegoś powodu, więc jestem pewien, że naprawienie tego nie może być proste. Opisanie tego z pewnością nie jest rozwiązaniem. Ale szukanie sposobu na przemycenie problemu przez kompilator również nie jest rozwiązaniem.

15ee8f99-57ff-4f92-890c-b56153
źródło
4
Jeśli widzę zwracającą się metodę ValueTask<T>, zakładam, że facet, który napisał metodę, zrobił to, ponieważ metoda faktycznie korzysta z funkcji, która ValueTask<T>dodaje. Nie rozumiem, dlaczego uważasz, że pożądane jest, aby wszystkie metody miały ten sam typ zwrotu. Jaki jest tam cel?
15ee8f99-57ff-4f92-890c-b56153
2
Celem 1 byłoby umożliwienie zmiany implementacji. Co się stanie, jeśli nie zapisuję teraz wyniku w pamięci podręcznej, ale dodam kod pamięci podręcznej później? Celem 2 byłoby stworzenie jasnych wytycznych dla zespołu, w których nie wszyscy członkowie rozumieją, kiedy ValueTask jest przydatne. Po prostu rób to zawsze, jeśli nie ma nic złego w korzystaniu z niego.
Stilgar
6
Moim zdaniem to prawie tak, jakby wszystkie twoje metody powróciły, objectponieważ może kiedyś będziesz chciał, aby powróciły intzamiast string.
15ee8f99-57ff-4f92-890c-b56153
3
Może, ale jeśli zadasz pytanie "dlaczego miałbyś kiedykolwiek zwrócić ciąg znaków zamiast obiektu", mogę z łatwością odpowiedzieć, wskazując na brak bezpieczeństwa typów. Powody, dla których warto nie używać ValueTask wszędzie, wydają się znacznie subtelniejsze.
Stilgar
3
@Stilgar Powiedziałbym tylko: subtelne problemy powinny martwić Cię bardziej niż oczywiste.
15ee8f99-57ff-4f92-890c-b56153
20

W .Net Core 2.1 nastąpiły pewne zmiany . Począwszy od .net core 2.1 ValueTask może reprezentować nie tylko ukończone synchroniczne akcje, ale także zakończoną asynchronizację. Dodatkowo otrzymujemy ValueTasktyp nieogólny .

Zostawię komentarz Stephena Toub, który jest związany z Twoim pytaniem:

Nadal musimy sformalizować wskazówki, ale spodziewam się, że będzie to mniej więcej tak dla obszaru publicznego interfejsu API:

  • Zadanie zapewnia największą użyteczność.

  • ValueTask zapewnia najwięcej opcji optymalizacji wydajności.

  • Jeśli piszesz interfejs / metodę wirtualną, którą inni zastąpią, ValueTask jest właściwym wyborem domyślnym.

  • Jeśli oczekujesz, że interfejs API będzie używany na gorących ścieżkach, w których alokacje będą miały znaczenie, ValueTask jest dobrym wyborem.

  • W przeciwnym razie, gdy wydajność nie jest krytyczna, domyślnie Task, ponieważ zapewnia lepsze gwarancje i użyteczność.

Z perspektywy implementacji wiele zwróconych wystąpień ValueTask będzie nadal obsługiwanych przez Task.

Funkcję można wykorzystać nie tylko w .net core 2.1. Będziesz mógł go używać z pakietem System.Threading.Tasks.Extensions .

unsafePtr
źródło
1
Więcej od Stephena dzisiaj: blogs.msdn.microsoft.com/dotnet/2018/11/07/…
Mike Cheel