Warum hängt diese asynchrone Aktion?

102

Ich habe eine mehrschichtige .NET 4.5-Anwendung, die eine Methode mit C #s neuem asyncund aufruftawait Schlüsselwörtern , die nur hängt, und ich kann nicht verstehen, warum.

Unten habe ich eine asynchrone Methode, die unser Datenbankdienstprogramm erweitert OurDBConn(im Grunde ein Wrapper für den Basiswert DBConnectionund die DBCommandObjekte):

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;
}

Dann habe ich eine asynchrone Methode der mittleren Ebene, die dies aufruft, um einige langsam laufende Summen zu erhalten:

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

    return result;
}

Endlich habe ich eine UI-Methode (eine MVC-Aktion), die synchron ausgeführt wird:

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

// do other stuff that takes a few seconds

ResultClass slowTotal = asyncTask.Result;

Das Problem ist, dass es für immer in dieser letzten Zeile hängt. Es macht das gleiche, wenn ich anrufeasyncTask.Wait() . Wenn ich die langsame SQL-Methode direkt ausführe, dauert es ungefähr 4 Sekunden.

Das Verhalten, das ich erwarte, ist das, wenn es darum geht asyncTask.Result fertig ist, wenn es nicht fertig ist, es warten sollte, bis es ist, und sobald es ist, sollte es das Ergebnis zurückgeben.

Wenn ich mit einem Debugger durchkomme, wird die SQL-Anweisung abgeschlossen und die Lambda-Funktion beendet, aber die return result;Zeile vonGetTotalAsync wird nie erreicht.

Irgendeine Idee, was ich falsch mache?

Irgendwelche Vorschläge, wo ich nachforschen muss, um dies zu beheben?

Könnte dies irgendwo ein Deadlock sein, und wenn ja, gibt es einen direkten Weg, ihn zu finden?

Keith
quelle

Antworten:

150

Ja, das ist eine Sackgasse. Und ein häufiger Fehler mit der TPL, also fühlen Sie sich nicht schlecht.

Wenn Sie schreiben await foo, plant die Laufzeit standardmäßig die Fortsetzung der Funktion auf demselben SynchronizationContext, auf dem die Methode gestartet wurde. Nehmen wir auf Englisch an, Sie haben Ihre ExecuteAsyncüber den UI-Thread angerufen . Ihre Abfrage wird im Threadpool-Thread ausgeführt (weil Sie aufgerufen haben Task.Run), aber Sie warten dann auf das Ergebnis. Dies bedeutet, dass die Laufzeit Ihre " return result;" Zeile so plant , dass sie auf dem UI-Thread zurückläuft, anstatt sie zurück in den Threadpool zu planen.

Wie kommt es zu diesem Stillstand? Stellen Sie sich vor, Sie haben nur diesen Code:

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

Die erste Zeile startet also die asynchrone Arbeit. Die zweite Zeile blockiert dann den UI-Thread . Wenn die Laufzeit die Zeile "return result" wieder im UI-Thread ausführen möchte, kann sie dies erst tun, wenn dieResult Vorgang abgeschlossen ist. Aber natürlich kann das Ergebnis nicht angegeben werden, bis die Rückgabe erfolgt. Sackgasse.

Dies zeigt eine wichtige Regel für die Verwendung der TPL: Wenn Sie .Resulteinen UI-Thread (oder einen anderen ausgefallenen Synchronisierungskontext) verwenden, müssen Sie darauf achten, dass für den UI-Thread nichts geplant ist, von dem Task abhängig ist. Sonst passiert Böses.

Also, was machst du? Option 1 wird überall verwendet, aber wie Sie sagten, ist dies bereits keine Option. Die zweite Option, die Ihnen zur Verfügung steht, besteht darin, die Verwendung von "Warten" einfach zu beenden. Sie können Ihre beiden Funktionen wie folgt umschreiben:

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"));
}

Was ist der Unterschied? Es gibt jetzt keine Wartezeiten mehr, sodass nichts implizit für den UI-Thread geplant ist. Für einfache Methoden wie diese, die eine einzige Rückgabe haben, macht es keinen Sinn, eine "var result = await...; return result " Muster zu erstellen. Entfernen Sie einfach den Async-Modifikator und geben Sie das Task-Objekt direkt weiter. Es ist weniger Aufwand, wenn nichts anderes.

Option 3 besteht darin, anzugeben, dass Ihre Wartezeiten nicht auf den UI-Thread zurückgesetzt werden sollen, sondern nur auf den Thread-Pool. Sie tun dies mit der ConfigureAwaitMethode wie folgt:

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);
}

Das Warten auf eine Aufgabe wird normalerweise für den UI-Thread geplant, wenn Sie sich darauf befinden. ContinueAwaitWenn Sie auf das Ergebnis von warten, wird der Kontext, in dem Sie sich befinden, ignoriert, und es wird immer ein Zeitplan für den Threadpool erstellt. Der Nachteil dabei ist, dass Sie dies überall in allen Funktionen, von denen Ihr Ergebnis abhängt, einstreuen müssen, da jedes Versäumte .ConfigureAwaitdie Ursache für einen weiteren Deadlock sein kann.

Jason Malinowski
quelle
6
Übrigens, die Frage bezieht sich auf ASP.NET, daher gibt es keinen UI-Thread. Das Problem mit Deadlocks ist jedoch aufgrund von ASP.NET genau dasselbe SynchronizationContext.
Svick
Das erklärte viel, da ich einen ähnlichen .NET 4-Code hatte, der das Problem nicht hatte, aber die TPL ohne die Schlüsselwörter async/ verwendete await.
Keith
2
TPL = Task Parallel Library msdn.microsoft.com/en-us/library/dd460717(v=vs.110).aspx
Jamie Ide
Wenn jemand nach dem VB.net-Code sucht (wie ich), wird er hier erklärt: docs.microsoft.com/en-us/dotnet/visual-basic/programming-guide/…
MichaelDarkBlue
Können Sie mir bitte in stackoverflow.com/questions/54360300/…
Jitendra Pancholi
36

Dies ist das klassische Mixed- asyncDeadlock-Szenario, wie ich es in meinem Blog beschreibe . Jason hat es gut beschrieben: Standardmäßig wird bei jedem ein "Kontext" gespeichert awaitund verwendet, um die asyncMethode fortzusetzen . Dieser "Kontext" ist der Strom, es SynchronizationContextsei denn null, er ist der Strom TaskScheduler. In diesem Fall ist er der Strom . Wenn die asyncMethode versucht, fortzufahren, tritt sie zuerst erneut in den erfassten "Kontext" ein (in diesem Fall ein ASP.NET SynchronizationContext). Das ASP.NET SynchronizationContexterlaubt jeweils nur einen Thread im Kontext, und es befindet sich bereits ein Thread im Kontext - der Thread ist blockiert Task.Result.

Es gibt zwei Richtlinien, die diesen Deadlock vermeiden:

  1. Verwenden Sie den asyncganzen Weg nach unten. Sie erwähnen, dass Sie dies "nicht" können, aber ich bin mir nicht sicher, warum nicht. ASP.NET MVC unter .NET 4.5 kann sicherlich asyncAktionen unterstützen, und es ist keine schwierige Änderung.
  2. Verwenden Sie ConfigureAwait(continueOnCapturedContext: false)so viel wie möglich. Dies überschreibt das Standardverhalten der Wiederaufnahme des erfassten Kontexts.
Stephen Cleary
quelle
Ist ConfigureAwait(false)gewährleistet , dass die aktuelle Funktion auf einem anderen Kontext wieder aufnimmt?
Chue x
Das MVC-Framework unterstützt dies, dies ist jedoch Teil einer vorhandenen MVC-App, in der bereits viele clientseitige JS vorhanden sind. Ich kann nicht einfach zu einer asyncAktion wechseln, ohne die Funktionsweise des Clients zu beeinträchtigen. Ich habe jedoch vor, diese Option längerfristig zu untersuchen.
Keith
Nur um meinen Kommentar zu verdeutlichen: Ich war neugierig, ob die Verwendung ConfigureAwait(false)des Anrufbaums das Problem des OP gelöst hätte.
Chue x
3
@Keith: Das Ausführen einer MVC-Aktion asyncwirkt sich überhaupt nicht auf die Clientseite aus. Ich erkläre dies in einem anderen Blog-Beitrag, asyncÄndert das HTTP-Protokoll nicht .
Stephen Cleary
1
@ Keith: Es ist normal async, durch die Codebasis zu "wachsen". Wenn Ihr Controller - Methode auf asynchrone Operationen abhängen kann, dann ist die Basisklassenmethode sollte zurückkehren Task<ActionResult>. Der Übergang zu einem großen Projekt asyncist immer umständlich, da das Mischen asyncund Synchronisieren von Code schwierig und schwierig ist. Reiner asyncCode ist viel einfacher.
Stephen Cleary
12

Ich befand mich in derselben Deadlock-Situation, aber in meinem Fall, als ich eine asynchrone Methode von einer Synchronisierungsmethode aus aufrief, funktionierte für mich Folgendes:

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

Ist das ein guter Ansatz, eine Idee?

Danilow
quelle
Diese Lösung funktioniert auch für mich, aber ich bin mir nicht sicher, ob sie eine gute Lösung ist oder irgendwo kaputt geht. Jeder kann das erklären
Konstantin Vdovkin
Nun, endlich habe ich mich für diese Lösung entschieden und sie funktioniert in einer produktiven Umgebung ohne Probleme .....
Danilow
1
Ich denke, Sie erzielen mit Task.Run einen Leistungseinbruch. In meinem Test verdoppelt Task.Run die Ausführungszeit für eine 100-ms-http-Anforderung nahezu.
Timothy Gonzalez
1
Das macht Sinn, Sie erstellen eine neue Aufgabe zum Umschließen eines asynchronen Aufrufs. Leistung ist der Kompromiss
Danilow
Fantastisch, das hat auch bei mir funktioniert. Mein Fall wurde auch durch eine synchrone Methode verursacht, die eine asynchrone Methode aufruft. Danke dir!
Leonardo Spina
4

Nur um die akzeptierte Antwort zu ergänzen (nicht genug Repräsentanten, um sie zu kommentieren), trat dieses Problem beim Blockieren der Verwendung von " task.Resultevent" auf, obwohl alle awaitunten aufgeführten Ereignisse ConfigureAwait(false)wie in diesem Beispiel:

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);
}

Das Problem lag tatsächlich beim externen Bibliothekscode. Die asynchrone Bibliotheksmethode hat versucht, im aufrufenden Synchronisierungskontext fortzufahren, unabhängig davon, wie ich das Warten konfiguriert habe, was zu einem Deadlock führte.

Daher bestand die Antwort darin, meine eigene Version des externen Bibliothekscodes zu rollen ExternalLibraryStringAsync, damit er die gewünschten Fortsetzungseigenschaften aufweist.


falsche Antwort für historische Zwecke

Nach viel Schmerz und Angst fand ich die Lösung in diesem Blog-Beitrag (Strg-f für 'Deadlock') begraben. Es dreht sich um die Verwendung task.ContinueWithanstelle des Nackten task.Result.

Bisheriges Deadlocking-Beispiel:

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);
}

Vermeiden Sie den Deadlock wie folgt:

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
quelle
Wofür ist die Gegenstimme? Diese Lösung funktioniert bei mir.
Cameron Jeffers
Sie geben das Objekt zurück, bevor das Taskabgeschlossen wurde, und geben dem Aufrufer keine Möglichkeit, festzustellen, wann die Mutation des zurückgegebenen Objekts tatsächlich stattfindet.
Servy
hmm ja ich verstehe. Sollte ich also eine Art "Warten bis die Aufgabe abgeschlossen ist" -Methode verfügbar machen, die eine manuell blockierende while-Schleife (oder ähnliches) verwendet? Oder einen solchen Block in die GetFooSynchronousMethode packen ?
Cameron Jeffers
1
Wenn Sie dies tun, wird es zum Stillstand kommen. Sie müssen den gesamten Weg asynchronisieren, indem Sie a zurückgeben, Taskanstatt zu blockieren.
Servy
Leider ist das keine Option, die Klasse implementiert eine synchrone Schnittstelle, die ich nicht ändern kann.
Cameron Jeffers
0

schnelle Antwort: Ändern Sie diese Zeile

ResultClass slowTotal = asyncTask.Result;

zu

ResultClass slowTotal = await asyncTask;

Warum? Sie sollten .result nicht verwenden, um das Ergebnis von Aufgaben in den meisten Anwendungen außer Konsolenanwendungen abzurufen. Wenn Sie dies tun, bleibt Ihr Programm hängen, wenn es dort ankommt

Sie können auch den folgenden Code ausprobieren, wenn Sie .Result verwenden möchten

ResultClass slowTotal = Task.Run(async ()=>await asyncTask).Result;
Ramin
quelle