Czy wytyczne asynchroniczne / oczekujące na użycie w C # nie są sprzeczne z koncepcjami dobrej architektury i warstw abstrakcji?

103

To pytanie dotyczy języka C #, ale spodziewam się, że obejmie inne języki, takie jak Java lub TypeScript.

Microsoft zaleca najlepsze praktyki korzystania z wywołań asynchronicznych w .NET. Spośród tych rekomendacji wybierzmy dwa:

  • zmień podpis metod asynchronicznych, tak aby zwracały Zadanie lub Zadanie <> (w TypeScript byłoby to Obietnicą <>)
  • zmień nazwy metod asynchronicznych na końcówkę xxxAsync ()

Teraz, gdy zamieniasz synchroniczny komponent niskiego poziomu na asynchroniczny, wpływa to na cały stos aplikacji. Ponieważ async / await ma pozytywny wpływ tylko wtedy, gdy jest używany „do samego końca”, oznacza to, że podpis i nazwy metod każdej warstwy w aplikacji muszą zostać zmienione.

Dobra architektura często wymaga umieszczania abstrakcji między poszczególnymi warstwami, tak że zamiana komponentów niskiego poziomu na inne jest niewidoczna dla komponentów wyższego poziomu. W języku C # abstrakcje mają formę interfejsów. Jeśli wprowadzimy nowy komponent asynchroniczny niskiego poziomu, każdy interfejs w stosie wywołań musi zostać zmodyfikowany lub zastąpiony nowym interfejsem. Sposób rozwiązania problemu (asynchronizacja lub synchronizacja) w klasie implementującej nie jest już ukryty (abstrakcyjny) dla dzwoniących. Dzwoniący muszą wiedzieć, czy jest to synchronizacja czy asynchronizacja.

Czy asynchronizacja / oczekiwanie na najlepsze praktyki nie są sprzeczne z zasadami „dobrej architektury”?

Czy to oznacza, że ​​każdy interfejs (powiedzmy IEnumerable, IDataAccessLayer) potrzebuje swojego asynchronicznego odpowiednika (IAsyncEnumerable, IAsyncDataAccessLayer), aby można je było zamienić na stosie podczas przełączania na zależności asynchroniczne?

Jeśli posuniemy problem nieco dalej, czy nie byłoby łatwiej założyć, że każda metoda jest asynchroniczna (aby zwrócić Zadanie <> lub Obietnica <>), oraz metody synchronizacji wywołań asynchronicznych, gdy tak naprawdę nie są asynchronizować? Czy tego należy się spodziewać w przyszłych językach programowania?

corentinaltepe
źródło
5
Chociaż brzmi to jak świetne pytanie do dyskusji, myślę, że jest to zbyt oparte na opiniach, aby odpowiedzieć tutaj.
Euforia
22
@Euphoric: Myślę, że problem tutaj zarysowany jest głębszy niż wytyczne C #, to tylko symptom faktu, że zmiana części aplikacji na zachowanie asynchroniczne może mieć nielokalny wpływ na cały system. Więc moje wnętrzności mówią mi, że musi być na to nieoceniona odpowiedź, oparta na faktach technicznych. Dlatego zachęcam wszystkich tutaj, aby nie zamykali tego pytania zbyt wcześnie, zamiast tego poczekajmy, jakie odpowiedzi nadejdą (a jeśli są zbyt opiniotwórcy, nadal możemy głosować za zamknięciem).
Doc Brown,
25
@DocBrown Myślę, że głębsze pytanie brzmi: „Czy część systemu można zmienić z synchronicznej na asynchroniczną bez części, które od niej zależą, również muszą się zmienić?” Myślę, że odpowiedź na to pytanie brzmi „nie”. W takim przypadku nie widzę zastosowania „koncepcji dobrej architektury i warstw”.
Euforia
6
@Euphoric: brzmi jak dobra podstawa do nieocenionej odpowiedzi ;-)
Doc Brown
5
@Gherman: ponieważ C #, podobnie jak wiele języków, nie może przeciążać na podstawie samego typu zwrotu. Skończysz na metodach asynchronicznych, które mają taki sam podpis jak ich odpowiedniki synchronizacji (nie wszystkie mogą przyjąć CancellationToken, a te, które to robią, mogą chcieć podać wartość domyślną). Usunięcie istniejących metod synchronizacji (i proaktywne złamanie całego kodu) jest oczywistym początkiem.
Jeroen Mostert,

Odpowiedzi:

111

Jakiego koloru jest twoja funkcja?

Możesz być zainteresowany tym, jaki kolor ma twoja funkcja Boba Nystrom 1 .

W tym artykule opisuje fikcyjny język, w którym:

  • Każda funkcja ma kolor: niebieski lub czerwony.
  • Funkcja czerwona może wywoływać funkcje niebieskie lub czerwone, bez problemu.
  • Niebieska funkcja może wywoływać tylko niebieskie funkcje.

Choć jest fikcyjny, dzieje się to dość regularnie w językach programowania:

  • W C ++ metoda „const” może wywoływać tylko inne metody „const” this.
  • W Haskell funkcja nie-IO może wywoływać tylko funkcje inne niż IO.
  • W języku C # funkcja synchronizacji może wywoływać tylko funkcje synchronizacji 2 .

Jak zauważyłeś, z powodu tych reguł czerwone funkcje mają tendencję do rozprzestrzeniania się wokół podstawy kodu. Wstawiasz jeden i stopniowo kolonizuje on całą bazę kodu.

1 Bob Nystrom, oprócz blogowania, jest również częścią zespołu Dart i napisał tę małą serię Crafting Interpreters; wysoce zalecane dla każdego miłośnika języka programowania / kompilatora.

2 Niezupełnie prawda, ponieważ możesz wywołać funkcję asynchroniczną i blokować ją aż do jej powrotu, ale ...

Ograniczenia językowe

Zasadniczo jest to ograniczenie dotyczące języka / czasu wykonywania.

Język z wątkami M: N, na przykład Erlang i Go, nie ma asyncfunkcji: każda funkcja jest potencjalnie asynchroniczna, a jej „włókno” zostanie po prostu zawieszone, zamienione i zamienione z powrotem, gdy będzie ponownie gotowe.

C # poszedł z modelem wątków 1: 1 i dlatego postanowił odkryć synchroniczność w języku, aby uniknąć przypadkowego blokowania wątków.

Wobec ograniczeń językowych wytyczne kodowania muszą zostać dostosowane.

Matthieu M.
źródło
4
Funkcje We / Wy mają tendencję do rozprzestrzeniania się, ale z należytą starannością można w większości izolować je do funkcji w pobliżu (na stosie podczas wywoływania) punktów wejścia kodu. Możesz to zrobić, wywołując funkcje wywołujące funkcje We / Wy, a następnie inne funkcje przetwarzają dane wyjściowe z nich i zwracają wyniki potrzebne do dalszego We / Wy. Uważam, że ten styl znacznie ułatwia zarządzanie bazami kodu i zarządzanie nimi. Zastanawiam się, czy istnieje konsekwencja synchroniczności.
jpmc26,
16
Co rozumiesz przez wątki „M: N” i „1: 1”?
Captain Man,
14
@CaptainMan: Wątek 1: 1 oznacza odwzorowanie jednego wątku aplikacji na jeden wątek systemu operacyjnego, tak jest w przypadku języków takich jak C, C ++, Java lub C #. Natomiast wątki M: N oznaczają odwzorowanie wątków M aplikacji na wątki N OS; w przypadku Go wątek aplikacji nazywa się „goroutine”, w przypadku Erlanga nazywa się go „aktorem” i być może słyszałeś o nich jako „zielonych niciach” lub „włóknach”. Zapewniają współbieżność bez konieczności równoległości. Niestety artykuł w Wikipedii na ten temat jest raczej rzadki.
Matthieu M.,
2
Jest to nieco powiązane, ale myślę również, że ten pomysł „koloru funkcji” dotyczy również funkcji blokujących dane wejściowe od użytkownika, np. Modalne okna dialogowe, okna komunikatów, niektóre formy We / Wy konsoli itp., Które są narzędziami, które ramy mają od samego początku.
jrh
2
@MatthieuM. C # nie ma jednego wątku aplikacji na jeden wątek systemu operacyjnego i nigdy nie miał. Jest to bardzo oczywiste, gdy wchodzisz w interakcję z natywnym kodem, a szczególnie oczywiste, gdy działa w MS SQL. Oczywiście procedury współpracy były zawsze możliwe (i są jeszcze prostsze async); w rzeczywistości był to dość powszechny wzorzec budowania responsywnego interfejsu użytkownika. Czy to było tak piękne jak Erlang? Nie. Ale to wciąż daleko od C :)
Luaan,
82

Masz rację, istnieje tutaj sprzeczność, ale nie jest to „najlepsza praktyka”, która jest zła. Wynika to z faktu, że funkcja asynchroniczna zasadniczo różni się od funkcji synchronicznej. Zamiast czekać na wynik z jego zależności (zwykle niektóre operacje we / wy) tworzy zadanie do obsługi przez główną pętlę zdarzeń. To nie jest różnica, którą można dobrze ukryć pod abstrakcją.

max630
źródło
27
Odpowiedź jest tak prosta, jak to IMO. Różnica między procesem synchronicznym i asynchronicznym nie jest szczegółem implementacji - jest to semantycznie inna umowa.
Ant P
11
@AntP: Nie zgadzam się, że to takie proste; pojawia się w języku C #, ale nie na przykład w języku Go. Nie jest to zatem nieodłączna właściwość procesów asynchronicznych, jest to kwestia sposobu modelowania procesów asynchronicznych w danym języku.
Matthieu M.,
1
@MatthieuM. Tak, ale możesz użyć asyncmetod w języku C #, aby zapewnić kontrakty synchroniczne, jeśli chcesz. Jedyna różnica polega na tym, że Go jest domyślnie asynchroniczny, podczas gdy C # jest domyślnie synchroniczny. asyncdaje ci drugi model programowania - async jest abstrakcją (to, co faktycznie robi, zależy od środowiska wykonawczego, harmonogramu zadań, kontekstu synchronizacji, implementacji oczekiwania).
Luaan,
6

Metoda asynchroniczna zachowuje się inaczej niż metoda synchroniczna, o czym jestem pewien. W czasie wykonywania konwersja wywołania asynchronicznego na synchroniczne jest banalna, ale nie można tego powiedzieć przeciwnie. Tak więc logika staje się następująca: dlaczego nie stworzymy metod asynchronicznych dla każdej metody, która może jej wymagać i nie pozwolimy, by osoba wywołująca „przekonwertowała” w razie potrzeby metodę synchroniczną?

W pewnym sensie przypomina to metodę, która rzuca wyjątki i drugą, która jest „bezpieczna” i nie rzuca się nawet w przypadku błędu. W którym momencie program kodujący przesadza z dostarczaniem tych metod, które w przeciwnym razie mogłyby zostać przekonwertowane na inne?

Są w tym dwie szkoły myślenia: jedna polega na stworzeniu wielu metod, każda z nich wywołuje inną, być może prywatną metodę, umożliwiającą zapewnienie opcjonalnych parametrów lub drobnych zmian w zachowaniu, takich jak zachowanie asynchroniczne. Drugim jest zminimalizowanie metod interfejsu do niezbędnego minimum, pozostawiając dzwoniącemu samodzielne wykonanie niezbędnych modyfikacji.

Jeśli należysz do pierwszej szkoły, logika jest poświęcona klasie na połączenia synchroniczne i asynchroniczne, aby uniknąć podwojenia każdego połączenia. Microsoft ma tendencję do faworyzowania tej szkoły myślenia i zgodnie z konwencją, aby zachować spójność ze stylem preferowanym przez Microsoft, ty również musiałbyś mieć wersję Async, w taki sam sposób, w jaki interfejsy prawie zawsze zaczynają się od „ja”. Podkreślę, że nie jest to złe samo w sobie, ponieważ lepiej jest zachować spójny styl w projekcie niż robić to „we właściwy sposób” i radykalnie zmienić styl rozwoju, który dodajesz do projektu.

To powiedziawszy, preferuję drugą szkołę, która polega na minimalizacji metod interfejsu. Jeśli myślę, że metodę można wywołać w sposób asynchroniczny, metoda ta jest dla mnie asynchroniczna. Dzwoniący może zdecydować, czy czekać przed zakończeniem tego zadania, czy nie. Jeśli ten interfejs jest interfejsem do biblioteki, bardziej uzasadnione jest zrobienie tego w ten sposób, aby zminimalizować liczbę metod, które trzeba by wycofać lub dostosować. Jeśli interfejs jest do użytku wewnętrznego w moim projekcie, dodam metodę dla każdego potrzebnego wywołania w całym projekcie dla podanych parametrów i żadnych „dodatkowych” metod, a nawet wtedy, tylko jeśli zachowanie metody nie jest jeszcze uwzględnione za pomocą istniejącej metody.

Jednak, jak wiele rzeczy w tej dziedzinie, jest to w dużej mierze subiektywne. Oba podejścia mają swoje zalety i wady. Microsoft rozpoczął także konwencję dodawania liter oznaczających typ na początku nazwy zmiennej, a „m_” oznacza, że ​​jest członkiem, co prowadzi do nazw zmiennych takich jak m_pUser. Chodzi mi o to, że nawet Microsoft nie jest nieomylny i może popełniać błędy.

To powiedziawszy, jeśli twój projekt jest zgodny z konwencją Async, radzę ci go uszanować i kontynuować styl. I tylko po otrzymaniu własnego projektu możesz napisać go w najlepszy dla siebie sposób.

Neil
źródło
6
„W czasie wykonywania konwersja wywołania asynchronicznego na synchroniczne jest banalna”. Nie jestem pewien, czy jest dokładnie tak. W .NET używanie .Wait()metody itp. Może powodować negatywne konsekwencje, aw js, o ile wiem, nie jest to w ogóle możliwe.
max630
2
@ max630 Nie powiedziałem, że nie ma równoczesnych problemów do rozważenia, ale jeśli początkowo było to zadanie synchroniczne , są szanse, że nie spowoduje to impasu. To powiedziawszy, trywialne nie oznacza „kliknij dwukrotnie tutaj, aby przekonwertować na synchroniczne”. W js zwracasz instancję Promise i wywołujesz dla niej połączenie.
Neil,
2
tak, to totalny ból w dupie, aby przekonwertować asynchronię z powrotem na synchronizację
Ewan
4
@Neil W javascript, nawet jeśli zadzwonisz, Promise.resolve(x)a następnie dodasz wywołania zwrotne, wywołania te nie zostaną wykonane natychmiast.
NickL,
1
@ Brak, jeśli interfejs ujawnia metodę asynchroniczną, oczekiwanie, że oczekiwanie na Zadanie nie spowoduje impasu, nie jest dobrym założeniem. Interfejs znacznie lepiej pokazuje, że jest rzeczywiście synchroniczny w podpisie metody, niż obietnica w dokumentacji, która może ulec zmianie w późniejszej wersji.
Carl Walsh,
2

Wyobraźmy sobie, że istnieje sposób na wywołanie funkcji w sposób asynchroniczny bez zmiany ich podpisu.

To byłoby naprawdę fajne i nikt nie zaleciłby zmiany ich nazw.

Ale rzeczywiste funkcje asynchroniczne, nie tylko te, które oczekują na inną funkcję asynchroniczną, ale najniższy poziom mają dla nich pewną strukturę charakterystyczną dla ich asynchronicznej natury. na przykład

public class HTTPClient
{
    public HTTPResponse GET()
    {
        //send data
        while(!timedOut)
        {
            //check for response
            if(response) { 
                this.GotResponse(response); 
            }
            this.YouCanWait();
        }
    }

    //tell calling code that they should watch for this event
    public EventHander GotResponse
    //indicate to calling code that they can go and do something else for a bit
    public EventHander YouCanWait;
}

Są to te dwie informacje, których potrzebuje kod wywołujący, aby uruchomić kod w sposób asynchroniczny, który jest podobny Taski asynchermetyzowany.

Jest więcej niż jeden sposób wykonywania funkcji asynchronicznych, async Taskjest tylko jeden wzorzec wbudowany w kompilator za pośrednictwem typów zwracanych, dzięki czemu nie trzeba ręcznie łączyć zdarzeń

Ewan
źródło
0

Zajmę się głównym punktem w sposób mniej C # ness i bardziej ogólny:

Czy asynchronizacja / oczekiwanie na najlepsze praktyki nie są sprzeczne z zasadami „dobrej architektury”?

Powiedziałbym, że zależy to tylko od wyboru dokonanego przy projektowaniu interfejsu API i tego, co pozwolisz użytkownikowi.

Jeśli chcesz, aby jedna funkcja interfejsu API była tylko asynchroniczna, nie ma większego zainteresowania przestrzeganiem konwencji nazewnictwa. Po prostu zawsze zwracaj Zadanie <> / Obietnica <> / Przyszłość <> / ... jako typ zwrotu, jest to samo dokumentowanie. Jeśli chce zsynchronizować odpowiedź, nadal będzie w stanie to zrobić, czekając, ale jeśli zawsze to zrobi, zrobi to trochę bzdury.

Jeśli jednak synchronizujesz tylko interfejs API, oznacza to, że jeśli użytkownik chce, aby był on asynchroniczny, będzie musiał sam zarządzać częścią asynchroniczną.

Może to spowodować sporo dodatkowej pracy, ale może również dać użytkownikowi większą kontrolę nad tym, ile dozwolonych jest jednoczesnych połączeń, limit czasu, retrys i tak dalej.

W dużym systemie z ogromnym interfejsem API wdrożenie większości z nich w celu domyślnej synchronizacji może być łatwiejsze i bardziej wydajne niż niezależne zarządzanie każdą częścią interfejsu API, zwłaszcza jeśli współużytkują zasoby (system plików, procesor, baza danych ...).

W rzeczywistości w przypadku najbardziej skomplikowanych części można idealnie mieć dwie implementacje tej samej części interfejsu API, jedną synchroniczną wykonującą przydatne funkcje, drugą asynchroniczną polegającą na synchronicznej obsługującą różne operacje i zarządzającą tylko współbieżnością, obciążeniami, limitami czasu i ponownymi próbami .

Może ktoś inny może podzielić się z tym swoim doświadczeniem, ponieważ brakuje mi doświadczenia z takimi systemami.

Walfrat
źródło
2
@Miral W obu możliwościach użyto „wywołania metody asynchronicznej z metody synchronizacji”.
Adrian Wragg,
@AdrianWragg Tak zrobiłem; mój mózg musiał mieć rasę. Naprawię to.
Miral,
Jest na odwrót; wywoływanie metody asynchronicznej z metody synchronizacji jest trywialne, ale nie można wywoływać metody synchronizacji z metody asynchronicznej. (A gdzie rzeczy całkowicie się rozpadają, gdy ktoś i tak próbuje to zrobić, co może prowadzić do impasu). Więc jeśli musiałeś wybrać jeden, domyślnie asynchronizacja jest lepszym wyborem. Niestety jest to również trudniejszy wybór, ponieważ implementacja asynchroniczna może wywoływać tylko metody asynchroniczne.
Miral,
(I przez to oczywiście mam na myśli metodę synchronizacji blokującej . Możesz wywołać coś, co wykonuje synchroniczne obliczenia związane wyłącznie z procesorem z metody asynchronicznej - chociaż powinieneś starać się tego unikać, chyba że wiesz, że jesteś w kontekście roboczym zamiast kontekstu interfejsu użytkownika - ale blokowanie wywołań, które czekają bezczynnie na zamku lub we / wy lub na inną operację, jest złym pomysłem.)
Miral