Dlaczego ta akcja asynchroniczna zawiesza się?

102

Mam wielowarstwową aplikację .Net 4,5 wywołującą metodę przy użyciu nowych asynci C #await słów kluczowych, które po prostu się zawieszają i nie rozumiem, dlaczego.

Na dole mam metodę asynchroniczną, która rozszerza nasze narzędzie bazy danych OurDBConn(w zasadzie opakowanie dla bazowego DBConnectioni DBCommandobiektów):

public static async Task<T> ExecuteAsync<T>(this OurDBConn dataSource, Func<OurDBConn, T> function)
{
    string connectionString = dataSource.ConnectionString;

    // Start the SQL and pass back to the caller until finished
    T result = await Task.Run(
        () =>
        {
            // Copy the SQL connection so that we don't get two commands running at the same time on the same open connection
            using (var ds = new OurDBConn(connectionString))
            {
                return function(ds);
            }
        });

    return result;
}

Następnie mam metodę asynchroniczną średniego poziomu, która wywołuje to, aby uzyskać wolno działające sumy:

public static async Task<ResultClass> GetTotalAsync( ... )
{
    var result = await this.DBConnection.ExecuteAsync<ResultClass>(
        ds => ds.Execute("select slow running data into result"));

    return result;
}

Wreszcie mam metodę UI (akcję MVC), która działa synchronicznie:

Task<ResultClass> asyncTask = midLevelClass.GetTotalAsync(...);

// do other stuff that takes a few seconds

ResultClass slowTotal = asyncTask.Result;

Problem w tym, że na zawsze wisi na ostatniej linii. Robi to samo, gdy dzwonięasyncTask.Wait() . Jeśli uruchomię wolną metodę SQL bezpośrednio, zajmie to około 4 sekund.

Spodziewam się zachowania, kiedy to nastąpi asyncTask.Result , jeśli nie zostanie zakończone, powinno poczekać, aż nastąpi, a gdy to się stanie, powinno zwrócić wynik.

Jeśli przejdę przez debuger, instrukcja SQL kończy się i funkcja lambda kończy się, ale return result;wierszGetTotalAsync nie zostanie osiągnięta.

Masz pojęcie, co robię źle?

Jakieś sugestie, gdzie muszę to zbadać, aby to naprawić?

Czy to może być gdzieś impas, a jeśli tak, czy istnieje bezpośredni sposób, aby go znaleźć?

Keith
źródło

Odpowiedzi:

150

Tak, to jest impas. I częsty błąd z OC, więc nie czuj się źle.

Podczas pisania await foośrodowisko uruchomieniowe domyślnie planuje kontynuację funkcji w tym samym SynchronizationContext, na którym została uruchomiona metoda. Załóżmy, że w języku angielskim ExecuteAsyncdzwoniłeś z wątku interfejsu użytkownika. Twoje zapytanie działa w wątku puli wątków (ponieważ zadzwoniłeś Task.Run), ale potem czekasz na wynik. Oznacza to, że środowisko wykonawcze zaplanuje " return result;" ponowne uruchomienie linii w wątku interfejsu użytkownika, zamiast zaplanować powrót do puli wątków.

Jak więc ten impas? Wyobraź sobie, że masz ten kod:

var task = dataSource.ExecuteAsync(_ => 42);
var result = task.Result;

Tak więc pierwsza linia rozpoczyna pracę asynchroniczną. Druga linia następnie blokuje wątek interfejsu użytkownika . Więc gdy środowisko wykonawcze chce ponownie uruchomić wiersz „zwracanego wyniku” w wątku interfejsu użytkownika, nie może tego zrobić, dopóki nie Resultzakończy się. Ale oczywiście nie można podać wyniku, dopóki nie nastąpi zwrot. Impas.

Ilustruje to kluczową zasadę korzystania z TPL: gdy używasz .Resultw wątku interfejsu użytkownika (lub innym fantazyjnym kontekście synchronizacji), należy uważać, aby nic, od czego zależy zadanie, nie zostało zaplanowane w wątku interfejsu użytkownika. Albo dzieje się zło.

Więc co robisz? Opcja nr 1 to użycie czekaj wszędzie, ale jak powiedziałeś, to już nie jest opcja. Drugą dostępną opcją jest po prostu zaprzestanie używania await. Możesz przepisać swoje dwie funkcje na:

public static Task<T> ExecuteAsync<T>(this OurDBConn dataSource, Func<OurDBConn, T> function)
{
    string connectionString = dataSource.ConnectionString;

    // Start the SQL and pass back to the caller until finished
    return Task.Run(
        () =>
        {
            // Copy the SQL connection so that we don't get two commands running at the same time on the same open connection
            using (var ds = new OurDBConn(connectionString))
            {
                return function(ds);
            }
        });
}

public static Task<ResultClass> GetTotalAsync( ... )
{
    return this.DBConnection.ExecuteAsync<ResultClass>(
        ds => ds.Execute("select slow running data into result"));
}

Co za różnica? Nie ma teraz żadnego oczekiwania, więc nic nie jest niejawnie zaplanowane w wątku interfejsu użytkownika. W przypadku prostych metod, takich jak te, które mają pojedynczy zwrot, nie ma sensu wykonywać „var result = await...; return result wzorca ”; po prostu usuń modyfikator async i przekaż obiekt zadania bezpośrednio. To mniej narzutów, jeśli nic innego.

Opcja nr 3 polega na określeniu, że nie chcesz, aby oczekiwania były planowane z powrotem do wątku interfejsu użytkownika, ale po prostu planuj do puli wątków. Robisz to ConfigureAwaitmetodą, na przykład:

public static async Task<ResultClass> GetTotalAsync( ... )
{
    var resultTask = this.DBConnection.ExecuteAsync<ResultClass>(
        ds => return ds.Execute("select slow running data into result");

    return await resultTask.ConfigureAwait(false);
}

Oczekiwanie na zadanie normalnie spowoduje zaplanowanie wątku interfejsu użytkownika, jeśli jesteś na nim; oczekiwanie na wynik ContinueAwaitspowoduje zignorowanie dowolnego kontekstu, w którym się znajdujesz, i zawsze będzie planowane do puli wątków. Wadą tego jest to, że musisz posypać to wszędzie we wszystkich funkcjach, od których zależy twój wynik, ponieważ każdy brakujący .ConfigureAwaitmoże być przyczyną kolejnego impasu.

Jason Malinowski
źródło
6
Przy okazji, pytanie dotyczy ASP.NET, więc nie ma wątku interfejsu użytkownika. Ale problem z zakleszczeniami jest dokładnie taki sam, ponieważ ASP.NET SynchronizationContext.
Svick
To wiele wyjaśniało, ponieważ miałem podobny kod .Net 4, który nie powodował problemu, ale korzystał z TPL bez słów kluczowych async/ await.
Keith
2
TPL = Task Parallel Library msdn.microsoft.com/en-us/library/dd460717(v=vs.110).aspx
Jamie Ide
Jeśli ktoś szuka kodu VB.net (tak jak ja), wyjaśniono to tutaj: docs.microsoft.com/en-us/dotnet/visual-basic/programming-guide/ ...
MichaelDarkBlue
Czy możesz mi pomóc w stackoverflow.com/questions/54360300/…
Jitendra Pancholi
36

To jest klasyczny asyncscenariusz mieszanego impasu, jak opisuję na moim blogu . Jason dobrze to opisał: domyślnie „kontekst” jest zapisywany w każdym miejscu awaiti używany do kontynuowania asyncmetody. Ten „kontekst” jest aktualny, SynchronizationContextchyba że nim jest null, w którym to przypadku jest aktualny TaskScheduler. Gdy asyncmetoda próbuje kontynuować, najpierw ponownie wchodzi do przechwyconego „kontekstu” (w tym przypadku ASP.NET SynchronizationContext). ASP.NET SynchronizationContextzezwala tylko na jeden wątek w kontekście naraz, aw kontekście istnieje już wątek - wątek zablokowany Task.Result.

Istnieją dwie wskazówki, które pozwolą uniknąć tego impasu:

  1. Wykorzystaj asynccałą drogę w dół. Wspomniałeś, że „nie możesz” tego zrobić, ale nie jestem pewien, dlaczego nie. ASP.NET MVC na .NET 4.5 z pewnością może obsługiwać asyncakcje i nie jest to trudna zmiana.
  2. Używaj ConfigureAwait(continueOnCapturedContext: false)jak najwięcej. Zastępuje to domyślne zachowanie wznawiania w przechwyconym kontekście.
Stephen Cleary
źródło
Czy ConfigureAwait(false)gwarantuje, że obecna funkcja zostanie wznowiona w innym kontekście?
chue x
Struktura MVC obsługuje to, ale jest to część istniejącej aplikacji MVC z już obecnymi wieloma kodami JS po stronie klienta. Nie mogę łatwo przejść do asyncdziałania bez zerwania sposobu, w jaki działa to po stronie klienta. Z pewnością planuję jednak zbadać tę opcję w dłuższej perspektywie.
Keith,
Żeby wyjaśnić mój komentarz - byłem ciekawy, czy użycie ConfigureAwait(false)drzewa wywołań rozwiązałoby problem OP.
chue x
3
@Keith: Wykonanie akcji MVC w asyncogóle nie wpływa na stronę klienta. Wyjaśniam to w innym poście na blogu,async Nie zmienia protokołu HTTP .
Stephen Cleary
1
@Keith: To normalne, asyncże „rośnie” w bazie kodu. Jeśli metoda kontrolera może zależeć od operacji asynchronicznych, powinna zwrócić metodę klasy bazowej Task<ActionResult>. Przenoszenie dużego projektu do asyncjest zawsze niewygodne, ponieważ mieszanie asynci synchronizacja kodu jest trudne i skomplikowane. Czysty asynckod jest znacznie prostszy.
Stephen Cleary
12

Byłem w tej samej sytuacji zakleszczenia, ale w moim przypadku wywołanie metody asynchronicznej z metody synchronizacji działa dla mnie:

private static SiteMetadataCacheItem GetCachedItem()
{
      TenantService TS = new TenantService(); // my service datacontext
      var CachedItem = Task.Run(async ()=> 
               await TS.GetTenantDataAsync(TenantIdValue)
      ).Result; // dont deadlock anymore
}

czy to dobre podejście, jakiś pomysł?

Daniłow
źródło
U mnie też to rozwiązanie działa, ale nie jestem pewien, czy jest to dobre rozwiązanie, albo gdzieś się zepsuje. Każdy może to wyjaśnić
Konstantin Vdovkin
no w końcu poszedłem z tym rozwiązaniem i pracuje w produktywnym środowisku bez kłopotów .....
Daniłow
1
Myślę, że za pomocą Task.Run osiągasz sukces. W moich testach Task.Run prawie podwaja czas wykonania żądania HTTP 100ms.
Timothy Gonzalez
1
to ma sens, tworzysz nowe zadanie zawijania połączenia asynchronicznego, wydajność to kompromis
Danilow
Fantastycznie, to też zadziałało, mój przypadek był również spowodowany przez metodę synchroniczną wywołującą metodę asynchroniczną. Dziękuję Ci!
Leonardo Spina
4

Aby dodać do zaakceptowanej odpowiedzi (za mało przedstawiciela, aby skomentować), miałem ten problem podczas blokowania używania task.Result, zdarzenie chociaż każdy awaitponiżej miał ConfigureAwait(false), jak w tym przykładzie:

public Foo GetFooSynchronous()
{
    var foo = new Foo();
    foo.Info = GetInfoAsync.Result;  // often deadlocks in ASP.NET
    return foo;
}

private async Task<string> GetInfoAsync()
{ 
    return await ExternalLibraryStringAsync().ConfigureAwait(false);
}

Problem faktycznie dotyczył kodu biblioteki zewnętrznej. Metoda biblioteki asynchronicznej próbowała kontynuować w kontekście wywoływania synchronizacji, bez względu na to, jak skonfigurowałem oczekiwanie, co doprowadziło do zakleszczenia.

Dlatego odpowiedzią było zrolowanie własnej wersji kodu biblioteki zewnętrznej ExternalLibraryStringAsync, tak aby miała pożądane właściwości kontynuacji.


zła odpowiedź ze względów historycznych

Po wielu bólu i udręce znalazłem rozwiązanie ukryte w tym poście na blogu (Ctrl-f oznacza „impas”). Obraca się wokół używania task.ContinueWithzamiast gołego task.Result.

Przykład wcześniejszego zakleszczenia:

public Foo GetFooSynchronous()
{
    var foo = new Foo();
    foo.Info = GetInfoAsync.Result;  // often deadlocks in ASP.NET
    return foo;
}

private async Task<string> GetInfoAsync()
{ 
    return await ExternalLibraryStringAsync().ConfigureAwait(false);
}

Uniknij impasu w ten sposób:

public Foo GetFooSynchronous
{
    var foo = new Foo();
    GetInfoAsync()  // ContinueWith doesn't run until the task is complete
        .ContinueWith(task => foo.Info = task.Result);
    return foo;
}

private async Task<string> GetInfoAsync
{
    return await ExternalLibraryStringAsync().ConfigureAwait(false);
}
Cameron Jeffers
źródło
Po co głos przeciw? To rozwiązanie działa na mnie.
Cameron Jeffers
Zwracasz obiekt przed zakończeniem operacji Taski nie udostępniasz wywołującemu żadnych środków na określenie, kiedy faktycznie nastąpi mutacja zwróconego obiektu.
Servy
hmm tak rozumiem. Czy powinienem więc ujawnić jakąś metodę „poczekaj, aż zadanie się zakończy”, która wykorzystuje ręcznie blokującą pętlę while (lub coś w tym rodzaju)? Albo zapakować taki blok do GetFooSynchronousmetody?
Cameron Jeffers
1
Jeśli to zrobisz, będzie to impas. Musisz przeprowadzić pełną asynchronizację, zwracając a Taskzamiast blokować.
Servy
Niestety to nie jest opcja, klasa implementuje interfejs synchroniczny, którego nie mogę zmienić.
Cameron Jeffers
0

szybka odpowiedź: zmień tę linię

ResultClass slowTotal = asyncTask.Result;

do

ResultClass slowTotal = await asyncTask;

czemu? nie powinieneś używać .result do uzyskania wyniku zadań wewnątrz większości aplikacji z wyjątkiem aplikacji konsolowych, jeśli to zrobisz, twój program zawiesi się, gdy się tam dostanie

możesz również wypróbować poniższy kod, jeśli chcesz użyć .Result

ResultClass slowTotal = Task.Run(async ()=>await asyncTask).Result;
Ramin
źródło