Dlaczego wszystkie funkcje nie powinny być domyślnie asynchroniczne?

107

Asynchroniczny-Oczekujcie wzór .net 4.5 jest zmiana paradygmatu. To prawie zbyt piękne, aby mogło być prawdziwe.

Przeportowałem kod obciążony we / wy do async-await, ponieważ blokowanie to już przeszłość.

Sporo osób porównuje asynchroniczne oczekiwanie do plagi zombie i okazało się, że jest to raczej trafne. Kod asynchroniczny lubi inny kod asynchroniczny (potrzebujesz funkcji asynchronicznej, aby czekać na funkcję asynchroniczną). Dlatego coraz więcej funkcji staje się asynchronicznych, a to stale rośnie w twojej bazie kodu.

Zmiana funkcji na asynchroniczną jest nieco powtarzalną i pozbawioną wyobraźni pracą. Wrzuć asyncsłowo kluczowe do deklaracji, zawiń wartość zwracaną przez Task<>i gotowe. To raczej niepokojące, jak łatwy jest cały proces, a wkrótce skrypt zastępujący tekst zautomatyzuje dla mnie większość „portowania”.

A teraz pytanie… Jeśli cały mój kod powoli zmienia się w asynchroniczny, dlaczego nie ustawić go domyślnie jako asynchronicznego?

Zakładam, że oczywistym powodem jest wydajność. Async-await ma swój narzut i kod, który nie musi być asynchroniczny, a najlepiej nie powinien. Ale jeśli jedynym problemem jest wydajność, z pewnością sprytne optymalizacje mogą automatycznie usunąć narzut, gdy nie jest potrzebny. Czytałem o optymalizacji „szybkiej ścieżki” i wydaje mi się, że sama powinna zająć się większością.

Być może jest to porównywalne do zmiany paradygmatu wprowadzonej przez śmieciarzy. We wczesnych dniach GC uwalnianie własnej pamięci było zdecydowanie bardziej wydajne. Ale masy nadal wybierają automatyczne zbieranie na rzecz bezpieczniejszego, prostszego kodu, który może być mniej wydajny (a nawet to prawdopodobnie nie jest już prawdą). Może tak powinno być w tym przypadku? Dlaczego nie wszystkie funkcje powinny być asynchroniczne?

talkol
źródło
8
Przyznaj zespołowi C # kredyt do oznaczania mapy. Tak jak setki lat temu, „tu leżą smoki”. Mógłbyś wyposażyć statek i udać się tam, całkiem prawdopodobne, że przeżyjesz ze słońcem świecącym i wiatrem w plecach. A czasami nie, nigdy nie wrócili. Podobnie jak async / await, SO jest wypełnione pytaniami od użytkowników, którzy nie rozumieli, w jaki sposób zeszli z mapy. Mimo że dostali całkiem dobre ostrzeżenie. Teraz to ich problem, a nie problem zespołu C #. Oznaczali smoki.
Hans Passant
1
@Sayse, nawet jeśli usuniesz rozróżnienie między funkcjami sync i async, wywoływanie funkcji, które są zaimplementowane synchronicznie, nadal będą synchroniczne (tak jak Twój przykład
WriteLine
2
„Zawiń wartość zwracaną przez Task <>”. Jeśli twoja metoda nie musi być async(aby spełnić jakiś kontrakt), jest to prawdopodobnie zły pomysł. Otrzymujesz wady asynchroniczności (zwiększony koszt wywołań metod; konieczność użycia awaitw kodzie wywołującym), ale nie ma żadnych zalet.
svick
1
To naprawdę interesujące pytanie, ale może nie pasuje do SO. Możemy rozważyć migrację go do programistów.
Eric Lippert
1
Może czegoś mi brakuje, ale mam dokładnie to samo pytanie. Jeśli async / await zawsze występują w parach, a kod nadal musi czekać na zakończenie wykonywania, dlaczego po prostu zaktualizować istniejący framework .NET i utworzyć te metody, które muszą być async - domyślnie async BEZ dodatkowych słów kluczowych? Język już staje się tym, przed czym został zaprojektowany - słowem kluczowym spaghetti. Myślę, że powinni byli przestać to robić po zasugerowaniu „var”. Teraz mamy "dynamiczny", asyn / await ... itd ... Dlaczego nie macie tylko javascript .NET-ify? ;))
monstro

Odpowiedzi:

127

Po pierwsze, dziękuję za miłe słowa. To naprawdę niesamowita funkcja i cieszę się, że mogłem być jej małą częścią.

Jeśli cały mój kod powoli zmienia się na asynchroniczny, dlaczego nie ustawić go domyślnie jako asynchronicznego?

Cóż, przesadzasz; cały kod nie zmienia się w asynchroniczny. Kiedy dodajesz razem dwie „zwykłe” liczby całkowite, nie czekasz na wynik. Kiedy dodasz razem dwie przyszłe liczby całkowite, aby otrzymać trzecią przyszłą liczbę całkowitą - ponieważ to Task<int>jest liczba całkowita, do której uzyskasz dostęp w przyszłości - oczywiście prawdopodobnie będziesz czekać na wynik.

Głównym powodem, dla którego nie należy tworzyć wszystkich elementów asynchronicznych, jest to, że celem async / await jest ułatwienie pisania kodu w świecie z wieloma operacjami o dużym opóźnieniu . Zdecydowana większość twoich operacji nie ma dużych opóźnień, więc nie ma sensu przyjmować spadku wydajności, który zmniejsza to opóźnienie. Raczej, kilka kluczowych z twoich operacji ma duże opóźnienia, a te operacje powodują infekcję asynchronicznych zombie w całym kodzie.

jeśli wydajność jest jedynym problemem, z pewnością sprytne optymalizacje mogą automatycznie usunąć narzut, gdy nie jest potrzebny.

W teorii teoria i praktyka są podobne. W praktyce nigdy nie są.

Pozwólcie, że przedstawię trzy punkty przeciwko tego rodzaju transformacji, po której następuje optymalizacja.

Pierwsza kwestia to: asynchronizacja w C # / VB / F # jest zasadniczo ograniczoną formą przekazywania kontynuacji . Ogromna ilość badań przeprowadzona w społeczności języków funkcjonalnych zajęła się ustaleniem sposobów optymalizacji kodu, który w dużym stopniu wykorzystuje styl przekazywania kontynuacji. Zespół kompilatora prawdopodobnie musiałby rozwiązać bardzo podobne problemy w świecie, w którym „asynchroniczna” była wartością domyślną, a metody nie-asynchroniczne musiałyby zostać zidentyfikowane i de-asynchroniczne. Zespół C # nie jest tak naprawdę zainteresowany rozwiązywaniem otwartych problemów badawczych, więc to duże punkty przeciwko temu.

Po drugie, C # nie ma takiego poziomu „przejrzystości referencyjnej”, który sprawia, że ​​tego rodzaju optymalizacje są łatwiejsze do wykonania. Przez „przezroczystość referencyjną” rozumiem właściwość, od której wartość wyrażenia nie zależy, kiedy jest oceniane . Wyrażenia takie jak 2 + 2są referencyjnie przejrzyste; jeśli chcesz, możesz przeprowadzić ocenę w czasie kompilacji lub odłożyć ją do czasu wykonania i uzyskać tę samą odpowiedź. Ale wyrażenia takiego jak x+ynie można przesuwać w czasie, ponieważ x i y mogą się zmieniać w czasie .

Async znacznie utrudnia rozumowanie, kiedy wystąpi efekt uboczny. Przed asynchronizacją, jeśli powiedziałeś:

M();
N();

i M()był void M() { Q(); R(); }i N()był void N() { S(); T(); }, a Ri Sskutki uboczne produkty, to wiesz, że efektem ubocznym R w stanie przed efektem ubocznym S jest. Ale jeśli masz async void M() { await Q(); R(); }to nagle to wychodzi przez okno. Nie masz żadnej gwarancji, czy R()wydarzy się to przed, czy po S()(chyba że oczywiście M()jest to oczekiwane; ale oczywiście Tasknie trzeba czekać na to N()).

Teraz wyobraź sobie, że ta właściwość, że nie wiadomo już, w jakiej kolejności występują efekty uboczne, dotyczy każdego fragmentu kodu w twoim programie, z wyjątkiem tych, które optymalizator zdoła usunąć z asynchronizacji. Zasadniczo nie masz już pojęcia, które wyrażenia będą oceniane w jakiej kolejności, co oznacza, że ​​wszystkie wyrażenia muszą być referencyjnie przezroczyste, co jest trudne w języku takim jak C #.

Trzecią kwestią jest to, że musisz zapytać „dlaczego asynchronizacja jest tak wyjątkowa?” Jeśli masz zamiar argumentować, że każda operacja powinna być faktycznie Task<T>a, to musisz umieć odpowiedzieć na pytanie „dlaczego nie Lazy<T>?” lub „dlaczego nie Nullable<T>?” lub „dlaczego nie IEnumerable<T>?” Ponieważ mogliśmy to równie łatwo zrobić. Dlaczego nie miałoby być tak, że każda operacja jest podnoszona do wartości zerowej ? Albo każda operacja jest leniwie obliczana, a wynik jest zapisywany w pamięci podręcznej na później , lub wynik każdej operacji jest sekwencją wartości zamiast pojedynczej wartości . Następnie musisz spróbować zoptymalizować te sytuacje, w których wiesz, że „och, to nigdy nie może być zerowe, więc mogę wygenerować lepszy kod” i tak dalej.

Chodzi o to, że nie jest dla mnie jasne, Task<T>czy w rzeczywistości jest to takie szczególne uzasadnienie tak dużej ilości pracy.

Jeśli tego rodzaju rzeczy Cię interesują, polecam zbadanie języków funkcjonalnych, takich jak Haskell, które mają znacznie większą przejrzystość referencyjną i pozwalają na wszelkiego rodzaju ocenę poza kolejnością i automatyczne buforowanie. Haskell ma również znacznie silniejsze wsparcie w swoim systemie typów dla tego rodzaju „monadycznych podnoszenia”, o których wspomniałem.

Eric Lippert
źródło
Posiadanie funkcji asynchronicznych wywoływanych bez await nie ma dla mnie sensu (w typowym przypadku). Gdybyśmy mieli usunąć tę funkcję, kompilator mógłby sam zdecydować, czy funkcja jest asynchroniczna, czy nie (czy wywołuje await?). Wtedy moglibyśmy mieć identyczną składnię dla obu przypadków (async i sync) i używać tylko await w wywołaniach jako elementu różnicującego.
Plaga
Kontynuowałem dyskusję w programistach na twoją prośbę: programmers.stackexchange.com/questions/209872/ ...
talkol
@EricLippert - Jak zwykle bardzo fajna odpowiedź :) Byłem ciekawy, czy możesz wyjaśnić „wysokie opóźnienie”? Czy istnieje tutaj ogólny zakres w milisekundach? Próbuję tylko dowiedzieć się, gdzie jest dolna linia graniczna dla używania asynchronicznego, ponieważ nie chcę jej nadużywać.
Travis J
7
@TravisJ: Wskazówki: nie blokuj wątku interfejsu użytkownika dłużej niż 30 ms. Co więcej, ryzykujesz, że pauza zostanie zauważona przez użytkownika.
Eric Lippert
1
Wyzwaniem dla mnie jest to, że to, czy coś jest wykonywane synchronicznie czy asynchronicznie, jest szczegółem implementacji, który może się zmienić. Ale zmiana w tej implementacji wymusza efekt falowania za pośrednictwem kodu, który ją wywołuje, kodu, który ją wywołuje i tak dalej. W końcu zmieniamy kod ze względu na kod, od którego zależy, czego zwykle staramy się unikać. Lub używamy, async/awaitponieważ coś ukrytego pod warstwami abstrakcji może być asynchroniczne.
Scott Hannen
23

Dlaczego nie wszystkie funkcje powinny być asynchroniczne?

Jak wspomniałeś, wydajność jest jednym z powodów. Zwróć uwagę, że opcja „szybkiej ścieżki”, z którą utworzono łącze, poprawia wydajność w przypadku ukończonego zadania, ale nadal wymaga znacznie więcej instrukcji i narzutu w porównaniu do pojedynczego wywołania metody. W związku z tym, nawet z „szybką ścieżką”, dodajesz dużo złożoności i narzutów przy każdym wywołaniu metody asynchronicznej.

Kompatybilność wsteczna, a także kompatybilność z innymi językami (w tym scenariuszami międzyoperacyjnymi) również stałaby się problematyczna.

Drugi to kwestia złożoności i zamiaru. Operacje asynchroniczne zwiększają złożoność - w wielu przypadkach funkcje języka to ukrywają, ale jest wiele przypadków, w których tworzenie metod asynczdecydowanie zwiększa złożoność ich użycia. Jest to szczególnie ważne, jeśli nie masz kontekstu synchronizacji, ponieważ metody asynchroniczne mogą łatwo spowodować nieoczekiwane problemy z wątkami.

Ponadto istnieje wiele procedur, które z natury nie są asynchroniczne. Są one bardziej sensowne jako operacje synchroniczne. Na przykład zmuszanie Math.Sqrtdo bycia Task<double> Math.SqrtAsyncbyłoby śmieszne, ponieważ nie ma żadnego powodu, aby było to asynchroniczne. Zamiast asyncprzepychać swoją aplikację, skończyłbyś z awaitrozprzestrzenianiem się wszędzie .

Spowodowałoby to również całkowite zerwanie obecnego paradygmatu, a także spowodowałoby problemy z właściwościami (które w rzeczywistości są po prostu parami metod ... czy one też byłyby asynchroniczne?) I miałyby inne konsekwencje w projektowaniu frameworka i języka.

Jeśli wykonujesz dużo pracy związanej z IO, zauważysz, że asyncwszechobecne używanie jest świetnym dodatkiem, wiele z twoich procedur będzie async. Jednak gdy zaczynasz wykonywać pracę związaną z procesorem, generalnie tworzenie rzeczy asyncnie jest dobre - ukrywa fakt, że używasz cykli procesora w ramach interfejsu API, który wydaje się być asynchroniczny, ale tak naprawdę niekoniecznie jest naprawdę asynchroniczny.

Reed Copsey
źródło
Dokładnie to, co zamierzałem napisać (wydajność), kompatybilność wsteczna może być inną rzeczą, biblioteki DLL do użytku ze starszymi językami, które również nie obsługują async /
await
Tworzenie sqrt async nie jest śmieszne, jeśli po prostu usuniemy rozróżnienie między funkcjami sync i async
talkol
@talkol Chyba że to odwrócić - dlaczego powinno każda funkcja połączenia spojrzenie na złożoność asynchrony?
Reed Copsey
2
@talkol Twierdzę, że to niekoniecznie prawda - asynchronia może sama dodawać błędy, które są gorsze niż blokowanie ...
Reed Copsey
1
@talkol Jak to jest await FooAsync()prostsze niż Foo()? I zamiast czasami małego efektu domina, cały czas masz ogromny efekt domina i nazywasz to poprawą?
svick
4

Pomijając wydajność - asynchronizacja może wiązać się z kosztami produktywności. Na kliencie (WinForms, WPF, Windows Phone) jest to dobrodziejstwo dla produktywności. Ale na serwerze lub w innych scenariuszach bez interfejsu użytkownika płacisz za produktywność. Na pewno nie chcesz tam domyślnie korzystać z asynchronii. Użyj go, gdy potrzebujesz zalet skalowalności.

Użyj go, gdy jesteś w najlepszym miejscu. W innych przypadkach nie rób tego.

usr
źródło
2
+1 - Prosta próba posiadania mentalnej mapy kodu wykonującej 5 asynchronicznych operacji równolegle z losową sekwencją uzupełniania dla większości ludzi zapewni wystarczający ból na jeden dzień. Rozumowanie na temat zachowania kodu asynchronicznego (a tym samym z natury równoległego) jest znacznie trudniejsze niż stary, dobry kod synchroniczny ...
Alexei Levenkov
2

Uważam, że istnieje dobry powód, aby wszystkie metody były asynchroniczne, jeśli nie są do tego potrzebne - rozszerzalność. Selektywne tworzenie metod asynchronicznych działa tylko wtedy, gdy twój kod nigdy nie ewoluuje i wiesz, że metoda A () jest zawsze związana z procesorem (utrzymujesz synchronizację), a metoda B () jest zawsze związana we / wy (oznaczasz ją jako asynchroniczną).

Ale co, jeśli coś się zmieni? Tak, A () wykonuje obliczenia, ale w pewnym momencie w przyszłości trzeba było dodać tam rejestrowanie, raportowanie lub wywołanie zwrotne zdefiniowane przez użytkownika z implementacją, która nie może przewidzieć, lub algorytm został rozszerzony i obejmuje teraz nie tylko obliczenia procesora, ale też jakieś I / O? Będziesz musiał przekonwertować metodę na asynchroniczną, ale spowodowałoby to uszkodzenie interfejsu API, a wszyscy wywołujący na stosie również musieliby zostać zaktualizowani (a mogą to być nawet różne aplikacje od różnych dostawców). Lub będziesz musiał dodać wersję asynchroniczną wraz z wersją synchronizowaną, ale nie robi to dużej różnicy - użycie wersji synchronizowanej blokowałoby, a zatem jest mało akceptowalne.

Byłoby wspaniale, gdyby można było uczynić istniejącą metodę synchronizacji asynchroniczną bez zmiany interfejsu API. Ale w rzeczywistości nie mamy takiej opcji, jak sądzę, a używanie wersji asynchronicznej, nawet jeśli nie jest obecnie potrzebna, jest jedynym sposobem na zagwarantowanie, że nigdy nie napotkasz problemów ze zgodnością w przyszłości.

Alex
źródło
Choć wydaje się to skrajnym komentarzem, jest w nim dużo prawdy. Po pierwsze, z powodu tego problemu: „spowodowałoby to zerwanie API i wszystkich wywołań na stosie” async / await sprawia, że ​​aplikacja jest ściślej powiązana. Może łatwo złamać zasadę podstawienia Liskova, jeśli podklasa chce na przykład użyć async / await. Trudno też wyobrazić sobie architekturę mikrousług, w której większość metod nie wymagałaby async / await.
ItsAllABadJoke