Jak uniknąć naruszenia zasady OSUSZANIA, gdy trzeba mieć zarówno wersje asynchroniczne, jak i synchronizowane kodu?

15

Pracuję nad projektem, który musi obsługiwać zarówno wersję asynchroniczną, jak i synchronizacyjną tej samej logiki / metody. Na przykład muszę mieć:

public class Foo
{
   public bool IsIt()
   {
      using (var conn = new SqlConnection(DB.ConnString))
      {
         return conn.Query<bool>("SELECT IsIt FROM SomeTable");
      }
   }

   public async Task<bool> IsItAsync()
   {
      using (var conn = new SqlConnection(DB.ConnString))
      {
         return await conn.QueryAsync<bool>("SELECT IsIt FROM SomeTable");
      }
   }
}

Logika asynchronizacji i synchronizacji dla tych metod jest identyczna pod każdym względem, z wyjątkiem tego, że jedna jest asynchroniczna, a druga nie. Czy istnieje uzasadniony sposób uniknięcia naruszenia zasady DRY w tego rodzaju scenariuszu? Widziałem, że ludzie mówią, że możesz użyć GetAwaiter (). GetResult () na metodzie asynchronicznej i wywołać ją z metody synchronizacji? Czy ten wątek jest bezpieczny we wszystkich scenariuszach? Czy istnieje inny, lepszy sposób, aby to zrobić, czy też jestem zmuszony powielić logikę?

Marko
źródło
1
Czy możesz powiedzieć coś więcej o tym, co tutaj robisz? W szczególności, w jaki sposób osiągasz asynchronię w swojej metodzie asynchronicznej? Czy procesor roboczy o dużych opóźnieniach jest związany z procesorem czy we / wy?
Eric Lippert
„jeden jest asynchroniczny, a drugi nie” - czy jest różnica w kodzie metody, czy po prostu w interfejsie? (Oczywiście tylko interfejs return Task.FromResult(IsIt());)
Alexei Levenkov
Również przypuszczalnie wersje synchroniczna i asynchroniczna charakteryzują się dużym opóźnieniem. W jakich okolicznościach dzwoniący użyje wersji synchronicznej i jak ten dzwoniący złagodzi fakt, że odbiorca może wziąć miliardy nanosekund? Czy osoba dzwoniąca w wersji synchronicznej nie dba o to, że zawiesi interfejs użytkownika? Czy nie ma interfejsu użytkownika? Powiedz nam więcej o scenariuszu.
Eric Lippert
@EricLippert Dodałem konkretny fałszywy przykład, aby dać ci poczucie, że dwie bazy kodów są identyczne, z wyjątkiem faktu, że jedna jest asynchroniczna. Możesz łatwo wyobrazić sobie scenariusz, w którym ta metoda jest znacznie bardziej złożona, a wiersze i wiersze kodu muszą być powielone.
Marko
@AlexeiLevenkov Różnica jest rzeczywiście w kodzie, dodałem fikcyjny kod, aby go pokazać.
Marko

Odpowiedzi:

15

W swoim pytaniu zadałeś kilka pytań. Rozbiorę je nieco inaczej niż ty. Ale najpierw pozwól mi bezpośrednio odpowiedzieć na pytanie.

Wszyscy chcemy aparatu, który jest lekki, wysokiej jakości i tani, ale jak mówi przysłowie, można uzyskać maksymalnie dwa z tych trzech. Jesteś w tej samej sytuacji tutaj. Chcesz rozwiązania, które jest wydajne, bezpieczne i dzieli kod między ścieżkami synchronicznymi i asynchronicznymi. Dostaniesz tylko dwa z nich.

Pozwól mi wyjaśnić, dlaczego tak jest. Zaczniemy od tego pytania:


Widziałem, że ludzie mówią, że możesz użyć GetAwaiter().GetResult()metody asynchronicznej i wywołać ją z metody synchronizacji? Czy ten wątek jest bezpieczny we wszystkich scenariuszach?

Pytanie to brzmi: „czy mogę współdzielić ścieżki synchroniczne i asynchroniczne, czyniąc ścieżkę synchroniczną po prostu synchronicznym oczekiwaniem w wersji asynchronicznej?”

Pozwól, że wyrażę się jasno w tej kwestii, ponieważ jest to ważne:

NALEŻY NATYCHMIAST PRZESTAĆ SKORZYSTAĆ Z JAKICHKOLWIEK PORAD OD TYCH LUDZI .

To bardzo zła rada. Synchroniczne pobieranie wyniku z zadania asynchronicznego jest bardzo niebezpieczne, chyba że masz dowody, że zadanie zakończyło się normalnie lub nieprawidłowo .

Powodem, dla którego jest to bardzo zła rada, jest, cóż, rozważ ten scenariusz. Chcesz kosić trawnik, ale ostrze kosiarki jest zepsute. Zdecydowałeś się przestrzegać tego przepływu pracy:

  • Zamów nowe ostrze ze strony internetowej. Jest to asynchroniczna operacja o dużym opóźnieniu.
  • Synchronicznie czekaj - to znaczy śpij, dopóki nie będziesz mieć ostrza pod ręką .
  • Okresowo sprawdzaj skrzynkę pocztową, aby zobaczyć, czy ostrze dotarło.
  • Wyjmij ostrze z pudełka. Teraz masz to w ręku.
  • Zamontować ostrze w kosiarce.
  • Skosić trawnik.

Co się dzieje? Śpisz wiecznie, ponieważ operacja sprawdzania poczty jest teraz uzależniona od czegoś, co dzieje się po jej nadejściu .

Jest to bardzo proste , aby dostać się do tej sytuacji, kiedy synchronicznie czekać na dowolnym zadaniu. To zadanie mogło mieć zaplanowaną pracę w przyszłości wątku, który teraz czeka , a teraz ta przyszłość nigdy nie nadejdzie, ponieważ na nią czekasz.

Jeśli wykonujesz asynchroniczne oczekiwanie, wszystko jest w porządku! Od czasu do czasu sprawdzasz pocztę, a podczas oczekiwania robisz kanapkę, płacisz podatki lub cokolwiek innego; wykonujesz pracę podczas oczekiwania.

Nigdy nie czekaj synchronicznie. Jeśli zadanie zostało wykonane, nie jest konieczne . Jeśli zadanie nie zostanie wykonane, ale zaplanowane uruchomienie z bieżącego wątku, jest nieefektywne, ponieważ bieżący wątek może obsługiwać inną pracę zamiast czekać. Jeśli zadanie nie zostanie wykonane, a harmonogram uruchomi się na bieżącym wątku, zawiesi się, aby synchronicznie czekać. Nie ma żadnego dobrego powodu, aby ponownie synchronicznie czekać, chyba że już wiesz, że zadanie zostało zakończone .

Więcej informacji na ten temat można znaleźć na stronie

https://blog.stephencleary.com/2012/07/dont-block-on-async-code.html

Stephen wyjaśnia scenariusz ze świata rzeczywistego znacznie lepiej niż potrafię.


Rozważmy teraz „inny kierunek”. Czy możemy współdzielić kod, czyniąc wersję asynchroniczną po prostu wykonując wersję synchroniczną w wątku roboczym?

Jest to prawdopodobnie i prawdopodobnie zły pomysł z następujących powodów.

  • Jest nieefektywny, jeśli operacja synchroniczna działa we / w z dużym opóźnieniem. To zasadniczo zatrudnia pracownika i sprawia, że ​​ten pracownik śpi aż do wykonania zadania. Wątki są niesamowicie drogie . Domyślnie zużywają milion bajtów przestrzeni adresowej, zabierają czas, zajmują zasoby systemu operacyjnego; nie chcesz palić nici wykonując bezużyteczną pracę.

  • Operacja synchroniczna może nie zostać zapisana jako bezpieczna dla wątków.

  • Jest to bardziej rozsądna technika, jeśli praca z dużymi opóźnieniami jest związana z procesorem, ale jeśli tak, to prawdopodobnie nie chcesz po prostu przekazywać jej do wątku roboczego. Prawdopodobnie chcesz użyć biblioteki zadań równoległych do zrównoleglenia jej z jak największą liczbą procesorów, prawdopodobnie potrzebujesz logiki anulowania i nie możesz po prostu zmusić wersji synchronicznej do tego wszystkiego, ponieważ wtedy byłaby to już wersja asynchroniczna .

Dalsza lektura; ponownie Stephen wyjaśnia to bardzo wyraźnie:

Dlaczego nie skorzystać z Task.Run:

https://blog.stephencleary.com/2013/11/taskrun-etiquette-examples-using.html

Więcej scenariuszy „rób i nie rób” dla Task.Run:

https://blog.stephencleary.com/2013/11/taskrun-etiquette-examples-dont-use.html


Co nam zatem pozostawia? Obie techniki udostępniania kodu prowadzą do impasu lub znacznej nieefektywności. Doszliśmy do wniosku, że musisz dokonać wyboru. Czy chcesz program, który jest wydajny i poprawny i zachwyca dzwoniącego, czy chcesz zaoszczędzić kilka naciśnięć klawiszy związanych z duplikowaniem niewielkiej ilości kodu między ścieżkami synchronicznymi i asynchronicznymi? Obawiam się, że nie dostaniesz obu.

Eric Lippert
źródło
Czy możesz wyjaśnić, dlaczego powrót Task.Run(() => SynchronousMethod())w wersji asynchronicznej nie jest tym, czego chce OP?
Guillaume Sasdy
2
@GuillaumeSasdy: Oryginalny plakat odpowiedział na pytanie, czym jest praca: jest związana z IO. Uruchamianie zadań związanych z operacjami we / wy w wątku roboczym jest niepotrzebne!
Eric Lippert
W porządku, rozumiem. Myślę, że masz rację, a odpowiedź @StriplingWarrior wyjaśnia to jeszcze bardziej.
Guillaume Sasdy
2
@Marko prawie każde obejście, które blokuje wątek, ostatecznie (pod dużym obciążeniem) zużyje wszystkie wątki puli wątków (zwykle używane do wykonywania operacji asynchronicznych). W rezultacie wszystkie wątki będą czekać i żaden nie będzie dostępny, aby umożliwić operacjom wykonanie części kodu „operacja asynchroniczna zakończona”. Większość obejść jest dobra w scenariuszach niskiego obciążenia (ponieważ jest wiele wątków, nawet 2-3 są blokowane w ramach każdej pojedynczej operacji)… A jeśli możesz zagwarantować, że zakończenie operacji asynchronicznej działa na nowym wątku systemu operacyjnego (nie puli wątków), to może nawet działać we wszystkich przypadkach (płacisz więc wysoką cenę)
Aleksiej Lewenkow
2
Czasami naprawdę nie ma innego wyjścia niż „po prostu zsynchronizować wersję w wątku roboczym”. Na przykład w ten sposób Dns.GetHostEntryAsyncjest implementowany w .NET i FileStream.ReadAsyncdla niektórych typów plików. System operacyjny po prostu nie zapewnia interfejsu asynchronicznego, więc środowisko wykonawcze musi je sfałszować (i nie jest specyficzne dla języka - powiedzmy, środowisko wykonawcze Erlang uruchamia całe drzewo procesów roboczych , z wieloma wątkami w każdym z nich, aby zapewnić nieblokujące wejścia / wyjścia dysku i nazwę rozkład).
Joker_vD
6

Trudno jest odpowiedzieć na to jedno uniwersalne rozwiązanie. Niestety nie ma prostego, idealnego sposobu na ponowne użycie kodu asynchronicznego i synchronicznego. Ale oto kilka zasad do rozważenia:

  1. Kod asynchroniczny i synchroniczny często różni się zasadniczo. Kod asynchroniczny powinien zwykle zawierać na przykład token anulowania. I często kończy się to wywoływaniem różnych metod (tak jak twój przykład wywołuje Query()jedną i QueryAsync()drugą) lub konfigurowaniem połączeń z różnymi ustawieniami. Więc nawet jeśli jest strukturalnie podobny, często występują wystarczające różnice w zachowaniu, aby zasługiwały na to, aby traktować je jako oddzielny kod o różnych wymaganiach. Zwróć uwagę na różnice między implementacjami metod Async i Sync w klasie File, na przykład: nie podejmuje się żadnych starań, aby używały tego samego kodu
  2. Jeśli dostarczasz sygnaturę metody asynchronicznej w celu zaimplementowania interfejsu, ale akurat masz synchroniczną implementację (tzn. Nie ma nic asynchronicznego w działaniu tej metody), możesz po prostu wrócić Task.FromResult(...).
  3. Wszelkie synchroniczne elementy logiczne, które takie same między dwiema metodami, można wyodrębnić do osobnej metody pomocniczej i wykorzystać w obu metodach.

Powodzenia.

StriplingWarrior
źródło
-2

Łatwy; niech synchroniczny wywoła asynchroniczny. Istnieje nawet przydatna metoda, Task<T>aby to zrobić:

public class Foo
{
   public bool IsIt()
   {
      var task = IsItAsync(); //no await here; we want the Task

      //Some tasks end up scheduled to run before you get them;
      //don't try to run them a second time
      if((int)task.Status > (int)TaskStatus.Created)
          //this call will block the current thread,
          //and unlike Run()/Wait() will prefer the current 
          //thread's TaskScheduler instead of a new thread.
          task.RunSynchronously(); 

      //if IsItAsync() can throw exceptions,
      //you still need a Wait() call to bring those back from the Task
      try{ 
          task.Wait();
          return task.Result;
      }
      catch(Exception ex) 
      { 
          //Handle IsItAsync() exceptions here;
          //remember to return something if you don't rethrow              
      }
   }

   public async Task<bool> IsItAsync()
   {
      // Some async logic
   }
}
KeithS
źródło
1
Zobacz moją odpowiedź, dlaczego jest to najgorsza praktyka, której nigdy nie wolno ci robić.
Eric Lippert
1
Twoja odpowiedź dotyczy GetAwaiter (). GetResult (). Unikałem tego. W rzeczywistości czasami trzeba uruchomić metodę synchronicznie, aby użyć jej w stosie wywołań, którego nie można wykonać asynchronicznie. Jeśli synchronizacja nigdy nie powinna zostać wykonana, to jako (były) członek zespołu C #, powiedz mi, dlaczego umieściłeś nie jeden, ale dwa sposoby synchronicznego oczekiwania na asynchroniczne zadanie w TPL.
KeithS
Osobą, która zada to pytanie, będzie Stephen Toub, a nie ja. Pracowałem tylko po stronie językowej.
Eric Lippert