Pourquoi cette action asynchrone se bloque-t-elle?

102

J'ai une application .Net 4.5 multiniveau appelant une méthode utilisant le nouveau C # asyncawait mots-clés de et qui se bloque juste et je ne vois pas pourquoi.

En bas, j'ai une méthode asynchrone qui étend notre utilitaire de base de données OurDBConn(essentiellement un wrapper pour le sous DBConnection- jacent et les DBCommandobjets):

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

Ensuite, j'ai une méthode asynchrone de niveau intermédiaire qui appelle ceci pour obtenir des totaux lents:

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

    return result;
}

Enfin, j'ai une méthode d'interface utilisateur (une action MVC) qui s'exécute de manière synchrone:

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

// do other stuff that takes a few seconds

ResultClass slowTotal = asyncTask.Result;

Le problème est qu'il reste à jamais sur cette dernière ligne. Ça fait la même chose si j'appelle asyncTask.Wait(). Si j'exécute directement la méthode SQL lente, cela prend environ 4 secondes.

Le comportement auquel je m'attends est que lorsque cela arrive asyncTask.Result, si ce n'est pas fini, il doit attendre jusqu'à ce que ce soit le cas, et une fois qu'il est, il doit renvoyer le résultat.

Si je passe avec un débogueur, l'instruction SQL se termine et la fonction lambda se termine, mais le return result; ligne de GetTotalAsyncn'est jamais atteinte.

Une idée de ce que je fais mal?

Avez-vous des suggestions sur les domaines où je dois enquêter pour résoudre ce problème?

Cela pourrait-il être une impasse quelque part, et si oui, y a-t-il un moyen direct de le trouver?

Keith
la source

Réponses:

150

Oui, c'est une impasse, d'accord. Et une erreur courante avec le TPL, alors ne vous sentez pas mal.

Lorsque vous écrivez await foo, le runtime, par défaut, planifie la poursuite de la fonction sur le même SynchronizationContext que la méthode a démarré. En anglais, disons que vous avez appelé votre ExecuteAsyncdepuis le fil de l'interface utilisateur. Votre requête s'exécute sur le thread du pool de threads (car vous avez appelé Task.Run), mais vous attendez ensuite le résultat. Cela signifie que le runtime planifiera votre " return result;" ligne pour qu'elle s'exécute sur le thread d'interface utilisateur, plutôt que de la planifier de nouveau vers le pool de threads.

Alors, comment cette impasse? Imaginez que vous ayez juste ce code:

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

Ainsi, la première ligne lance le travail asynchrone. La deuxième ligne bloque ensuite le thread d'interface utilisateur . Ainsi, lorsque le moteur d'exécution souhaite réexécuter la ligne «return result» sur le thread d'interface utilisateur, il ne peut pas le faire tant que le fichier n'est pas Resultterminé. Mais bien sûr, le résultat ne peut être donné tant que le retour n'a pas eu lieu. Impasse.

Cela illustre une règle clé d'utilisation du TPL: lorsque vous utilisez .Resultsur un thread d'interface utilisateur (ou un autre contexte de synchronisation sophistiqué), vous devez faire attention à ce que rien dont la tâche dépend ne soit planifié sur le thread d'interface utilisateur. Ou bien le mal arrive.

Donc que fais-tu? L'option n ° 1 consiste à utiliser wait partout, mais comme vous l'avez dit, ce n'est déjà pas une option. La deuxième option disponible pour vous est simplement de cesser d'utiliser await. Vous pouvez réécrire vos deux fonctions pour:

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

Quelle est la différence? Il n'y a plus d'attente nulle part, donc rien n'est implicitement planifié dans le thread de l'interface utilisateur. Pour des méthodes simples comme celles-ci qui ont un seul retour, il est inutile de faire un var result = await...; return resultmotif " "; supprimez simplement le modificateur async et passez directement l'objet de tâche. C'est moins de frais généraux, si rien d'autre.

L'option n ° 3 consiste à spécifier que vous ne voulez pas que vos attentes soient planifiées de nouveau vers le thread d'interface utilisateur, mais simplement planifier le pool de threads. Vous faites cela avec la ConfigureAwaitméthode, comme ceci:

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

Attendre une tâche normalement planifierait le thread de l'interface utilisateur si vous y êtes; en attendant le résultat de ContinueAwaitignorera le contexte dans lequel vous vous trouvez et planifiera toujours le threadpool. L'inconvénient est que vous devez saupoudrer cela partout dans toutes les fonctions dont dépend votre .Result, car tout manquement .ConfigureAwaitpourrait être la cause d'un autre blocage.

Jason Malinowski
la source
6
BTW, la question concerne ASP.NET, il n'y a donc pas de thread d'interface utilisateur. Mais le problème avec les blocages est exactement le même, à cause de ASP.NET SynchronizationContext.
svick
Cela expliquait beaucoup, car j'avais un code .Net 4 similaire qui n'avait pas de problème mais qui utilisait le TPL sans les mots async- awaitclés / .
Keith
2
TPL = Task Parallel Library msdn.microsoft.com/en-us/library/dd460717(v=vs.110).aspx
Jamie Ide
Si quelqu'un cherche le code VB.net (comme moi), il est expliqué ici: docs.microsoft.com/en-us/dotnet/visual-basic/programming-guide/…
MichaelDarkBlue
Pouvez-vous s'il vous plaît m'aider dans stackoverflow.com/questions/54360300/…
Jitendra Pancholi
36

C'est le asyncscénario classique de blocage mixte, comme je le décris sur mon blog . Jason l'a bien décrit: par défaut, un "contexte" est sauvegardé à chaque fois awaitet utilisé pour continuer la asyncméthode. Ce «contexte» est le courant à SynchronizationContextmoins qu'il ne le soit null, auquel cas c'est le courant TaskScheduler. Lorsque la asyncméthode tente de continuer, elle rentre d'abord le «contexte» capturé (dans ce cas, un ASP.NET SynchronizationContext). ASP.NET SynchronizationContextn'autorise qu'un seul thread dans le contexte à la fois, et il existe déjà un thread dans le contexte - le thread est bloqué sur Task.Result.

Il existe deux directives pour éviter cette impasse:

  1. Utilisez asynctout en bas. Vous dites que vous «ne pouvez» pas faire cela, mais je ne sais pas pourquoi. ASP.NET MVC sur .NET 4.5 peut certainement prendre en charge des asyncactions, et ce n'est pas une modification difficile à apporter.
  2. Utilisez ConfigureAwait(continueOnCapturedContext: false)autant que possible. Cela remplace le comportement par défaut de la reprise sur le contexte capturé.
Stephen Cleary
la source
Garantit-il ConfigureAwait(false)que la fonction actuelle reprend dans un contexte différent?
chue x
Le framework MVC le prend en charge, mais cela fait partie d'une application MVC existante avec de nombreux JS côté client déjà présents. Je ne peux pas facilement passer à une asyncaction sans casser la façon dont cela fonctionne côté client. Je prévois certainement d'étudier cette option à plus long terme.
Keith
Juste pour clarifier mon commentaire - j'étais curieux de savoir si l'utilisation de ConfigureAwait(false)l'arbre d'appel aurait résolu le problème de l'OP.
chue x
3
@Keith: Faire une action MVC asyncn'affecte pas du tout le côté client. J'explique cela dans un autre article de blog, asyncNe change pas le protocole HTTP .
Stephen Cleary
1
@Keith: Il est normal asyncde "grandir" à travers la base de code. Si votre méthode de contrôleur peut dépendre d'opérations asynchrones, la méthode de classe de base doit retourner Task<ActionResult>. La transition d'un gros projet vers asyncest toujours délicate car le mélange asyncet la synchronisation du code sont difficiles et délicats. Le asynccode pur est beaucoup plus simple.
Stephen Cleary
12

J'étais dans la même situation de blocage, mais dans mon cas, en appelant une méthode async à partir d'une méthode de synchronisation, ce qui fonctionne pour moi était:

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

est-ce une bonne approche, une idée?

Danilow
la source
Cette solution fonctionne aussi pour moi, mais je ne suis pas sûr que ce soit une bonne solution ou qu'elle risque de casser quelque part. Tout le monde peut expliquer cela
Konstantin Vdovkin
enfin je suis allé avec cette solution et elle fonctionne dans un environnement productif sans soucis .....
Danilow
1
Je pense que vous prenez un coup de performance en utilisant Task.Run. Dans mes tests, Task.Run double presque le temps d'exécution pour une requête http de 100 ms.
Timothy Gonzalez
1
cela a du sens, vous créez une nouvelle tâche pour encapsuler un appel asynchrone, les performances sont le compromis
Danilow
Fantastique, cela a fonctionné pour moi aussi, mon cas a également été causé par une méthode synchrone appelant une méthode asynchrone. Je vous remercie!
Leonardo Spina
4

Juste pour ajouter à la réponse acceptée (pas assez de représentant pour commenter), j'ai eu ce problème lors du blocage de l'utilisation de task.Result, événement bien que tous les éléments awaitci-dessous ConfigureAwait(false), comme dans cet exemple:

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

Le problème résidait en fait avec le code de la bibliothèque externe. La méthode de bibliothèque async a essayé de continuer dans le contexte de synchronisation d'appel, quelle que soit la façon dont j'ai configuré l'attente, ce qui a entraîné un blocage.

Ainsi, la réponse était de rouler ma propre version du code de la bibliothèque externe ExternalLibraryStringAsync, de sorte qu'elle ait les propriétés de continuation souhaitées.


mauvaise réponse à des fins historiques

Après beaucoup de douleur et d'angoisse, j'ai trouvé la solution enfouie dans ce billet de blog (Ctrl-f pour «blocage»). Il tourne autour de l'utilisation task.ContinueWith, au lieu du simple task.Result.

Exemple de blocage précédent:

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

Évitez l'impasse comme ceci:

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
la source
À quoi sert le vote négatif? Cette solution fonctionne pour moi.
Cameron Jeffers
Vous retournez l'objet avant que le Taskne soit terminé et ne fournissez à l'appelant aucun moyen de déterminer quand la mutation de l'objet retourné se produit réellement.
Servy
hmm ouais je vois. Dois-je donc exposer une sorte de méthode "attendre la fin de la tâche" qui utilise une boucle while bloquant manuellement (ou quelque chose comme ça)? Ou intégrer un tel bloc dans la GetFooSynchronousméthode?
Cameron Jeffers
1
Si vous le faites, cela bloquera. Vous devez asynchroniser complètement en renvoyant un Taskau lieu de bloquer.
Servy
Malheureusement ce n'est pas une option, la classe implémente une interface synchrone que je ne peux pas changer.
Cameron Jeffers
0

réponse rapide: changez cette ligne

ResultClass slowTotal = asyncTask.Result;

à

ResultClass slowTotal = await asyncTask;

Pourquoi? vous ne devez pas utiliser .result pour obtenir le résultat des tâches dans la plupart des applications, à l'exception des applications de console, si vous le faites, votre programme se bloquera lorsqu'il y arrivera

vous pouvez également essayer le code ci-dessous si vous souhaitez utiliser .Result

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