Lequel d'entre nous ne tond pas? Je rencontre régulièrement des erreurs dans le code asynchrone et je les fais moi-même. Pour arrêter cette roue de Samsara, je partage avec vous les jambages les plus typiques de ceux qui sont parfois assez difficiles à attraper et à réparer.

Ce texte est inspiré du blog de Stephen Clary , un homme qui sait tout sur la compétitivité, l'asynchronie, le multithreading et autres mots effrayants. Il est l'auteur du livre Concurrency in C # Cookbook , qui a rassemblé un grand nombre de modèles pour travailler avec la concurrence.
Blocage asynchrone classique
Pour comprendre le blocage asynchrone, il vaut la peine de déterminer quel thread exécute la méthode invoquée à l'aide du mot clé wait.
Tout d'abord, la méthode va plonger dans la chaîne d'appels de méthodes asynchrones jusqu'à ce qu'elle rencontre une source d'asynchronie. La façon dont la source d'asynchronie est implémentée est un sujet qui dépasse le cadre de cet article. Maintenant, pour simplifier, nous supposons qu'il s'agit d'une opération qui ne nécessite pas de flux de travail en attendant son résultat, par exemple, une requête de base de données ou une requête HTTP. Le démarrage synchrone d'une telle opération signifie qu'en attendant son résultat dans le système, il y aura au moins un thread endormi qui consomme des ressources mais ne fait aucun travail utile.
Dans un appel asynchrone, nous interrompons en quelque sorte le flux d'exécution des commandes sur "avant" et "après" de l'opération asynchrone, et dans .NET, il n'y a aucune garantie que le code qui se trouve après l'attente sera exécuté dans le même thread que le code avant l'attente. Dans la plupart des cas, cela n'est pas nécessaire, mais que faire lorsqu'un tel comportement est vital pour que le programme fonctionne? Besoin d'utiliser SynchronizationContext
. Il s'agit d'un mécanisme qui vous permet d'imposer certaines restrictions sur les threads dans lesquels le code est exécuté. Ensuite, nous traiterons de deux contextes de synchronisation ( WindowsFormsSynchronizationContext
et AspNetSynchronizationContext
), mais Alex Davis écrit dans son livre qu'il y en a environ une douzaine en .NET. À propos de SynchronizationContext
bien écrit ici , ici et ici, l' auteur a implémenté le sien, pour lequel il a un grand respect.
Ainsi, dès que le code arrive à la source de l'asynchronie, il enregistre le contexte de synchronisation, qui était dans la propriété statique du thread de SynchronizationContext.Current
, puis l'opération asynchrone démarre et libère le thread actuel. En d'autres termes, en attendant la fin de l'opération asynchrone, nous ne bloquons pas un seul thread et c'est le principal bénéfice de l'opération asynchrone par rapport à l'opération synchrone. Après avoir terminé l'opération asynchrone, nous devons suivre les instructions qui se trouvent après la source asynchrone, et ici, pour décider dans quel thread exécuter le code après l'opération asynchrone, nous devons consulter le contexte de synchronisation enregistré précédemment. Comme il le dit, nous le ferons. Il vous dira d'exécuter dans le même thread que le code avant d'attendre - nous exécuterons dans le même thread, ne dirons pas - nous prendrons le premier thread du pool.
Mais que se passe-t-il si, dans ce cas particulier, il est important pour nous que le code après wait soit exécuté dans n'importe quel thread libre du pool de threads? Vous devez utiliser le mantra ConfigureAwait(false)
. La fausse valeur transmise au paramètre continueOnCapturedContext
indique au système que n'importe quel thread du pool peut être utilisé. Et que se passe-t-il si au moment de l'exécution de la méthode avec wait, il n'y avait aucun contexte de synchronisation ( SynchronizationContext.Current == null
), comme par exemple dans une application console. Dans ce cas, nous n'avons aucune restriction sur le thread dans lequel le code doit être exécuté après attendre et le système prendra le premier thread du pool, comme dans le cas de ConfigureAwait(false)
.
Qu'est-ce qu'un blocage asynchrone?
Blocage dans WPF et WinForms
La différence entre les applications WPF et WinForms est le contexte de synchronisation même. Le contexte de synchronisation de WPF et WinForms a un thread spécial - le thread d'interface utilisateur. Il existe un thread d'interface utilisateur par SynchronizationContext
et seul ce thread peut interagir avec les éléments de l'interface utilisateur. Par défaut, le code qui a commencé à fonctionner dans le thread d'interface utilisateur reprend son fonctionnement après une opération asynchrone dans celui-ci.
Voyons maintenant un exemple:
private void Button_Click(object sender, System.Windows.RoutedEventArgs e) { StartWork().Wait(); } private async Task StartWork() { await Task.Delay(100); var s = "Just to illustrate the instruction following await"; }
Que se passe-t-il lorsque vous appelez
StartWork().Wait()
:
- Le thread appelant (et il s'agit du thread d'interface utilisateur) entrera dans la méthode
StartWork
et StartWork
à l' await Task.Delay(100)
. - Le thread d'interface utilisateur démarrera l'opération asynchrone
Task.Delay(100)
, et il retournera le contrôle à la méthode Button_Click
, et là, la méthode Wait()
de la classe Task
l'attendra. Lorsque la méthode Wait()
est appelée, le thread d'interface utilisateur se bloquera jusqu'à la fin de l'opération asynchrone, et nous nous attendons à ce que dès qu'il se termine, le thread d'interface utilisateur reprendra immédiatement l'exécution et ira plus loin dans le code, cependant, tout ira mal. - Dès que
Task.Delay(100)
terminé, le thread d'interface utilisateur devra d'abord continuer à exécuter la méthode StartWork()
et pour cela, il a besoin exactement du thread dans lequel l'exécution a commencé. Mais le thread d'interface utilisateur attend maintenant le résultat de l'opération. StartWork()
: StartWork()
ne peut pas continuer l'exécution et renvoyer le résultat, et Button_Click
attend le même résultat, et en raison du fait que l'exécution a commencé dans le thread d'interface utilisateur, l'application se bloque simplement sans possibilité de continuer à fonctionner.
Cette situation peut être traitée tout simplement en changeant l'appel à
Task.Delay(100)
en
Task.Delay(100).ConfigureAwait(false)
:
private void Button_Click(object sender, System.Windows.RoutedEventArgs e) { StartWork().Wait(); } private async Task StartWork() { await Task.Delay(100).ConfigureAwait(false); var s = "Just to illustrate the instruction following await"; }
Ce code fonctionnera sans blocages, car maintenant un thread du pool peut être utilisé pour terminer la méthode StartWork()
, plutôt qu'un thread d'interface utilisateur bloqué. Stephen Clary recommande d'utiliser ConfigureAwait(false)
dans toutes les «méthodes de bibliothèque» de son blog, mais souligne spécifiquement que l'utilisation de ConfigureAwait(false)
pour traiter les blocages n'est pas une bonne pratique. Au lieu de cela, il conseille de NE PAS utiliser de méthodes de blocage telles que Wait()
, Result
, GetAwaiter().GetResult()
et de GetAwaiter().GetResult()
toutes les méthodes pour utiliser async / wait, si possible (le soi-disant principe Async all the way).
Blocage dans ASP.NET
ASP.NET a également un contexte de synchronisation, mais il a des limitations légèrement différentes. Il vous permet d'utiliser un seul thread par demande à la fois et requiert également que le code après attente soit exécuté dans le même thread que le code avant wait.
Un exemple:
public class HomeController : Controller { public ActionResult Deadlock() { StartWork().Wait(); return View(); } private async Task StartWork() { await Task.Delay(100); var s = "Just to illustrate the code following await"; } }
Ce code provoquera également un blocage, car au moment de l'appel à StartWork().Wait()
seul thread autorisé sera bloqué et attendra la StartWork()
opération StartWork()
, et il ne se terminera jamais, car le thread dans lequel l'exécution doit se poursuivre est occupé. en attente.
Tout cela est corrigé par le même ConfigureAwait(false)
.
Blocage dans ASP.NET Core (en fait pas)
Essayons maintenant d'exécuter le code de l'exemple pour ASP.NET dans le projet pour ASP.NET Core. Si nous le faisons, nous verrons qu'il n'y aura pas d'impasse. En effet, ASP.NET Core n'a pas de contexte de synchronisation . Super! Et maintenant, vous pouvez couvrir le code avec des appels bloquants et ne pas avoir peur des blocages? Strictement parlant, oui, mais rappelez-vous que cela provoque l'endormissement du thread en attendant, c'est-à-dire que le thread consomme des ressources, mais ne fait aucun travail utile.
N'oubliez pas que l'utilisation des appels bloquants élimine tous les avantages d'une programmation asynchrone en la transformant en synchrone . Oui, parfois sans utiliser Wait()
cela ne fonctionnera pas pour écrire un programme, mais la raison doit être sérieuse.
Utilisation erronée de Task.Run ()
La méthode Task.Run()
été créée pour démarrer les opérations dans un nouveau thread. Comme il sied à une méthode écrite dans un modèle TAP, elle retourne Task
ou Task<T>
et les personnes confrontées à async / wait pour la première fois ont un grand désir d'envelopper le code synchrone dans Task.Run()
et eutite le résultat de cette méthode. Le code semblait devenir asynchrone, mais en fait, rien n'a changé. Voyons ce qui se passe avec cette utilisation de Task.Run()
.
Un exemple:
private static async Task ExecuteOperation() { Console.WriteLine($"Before: {Thread.CurrentThread.ManagedThreadId}"); await Task.Run(() => { Console.WriteLine($"Inside before sleep: {Thread.CurrentThread.ManagedThreadId}"); Thread.Sleep(1000); Console.WriteLine($"Inside after sleep: {Thread.CurrentThread.ManagedThreadId}"); }); Console.WriteLine($"After: {Thread.CurrentThread.ManagedThreadId}"); }
Le résultat de ce code sera:
Before: 1 Inside before sleep: 3 Inside after sleep: 3 After: 3
Ici, Thread.Sleep(1000)
est une sorte d'opération synchrone qui nécessite un thread pour se terminer. Supposons que nous voulons rendre notre solution asynchrone et que cette opération puisse être euthanasiée, nous l'avons enveloppée dans Task.Run()
.
Dès que le code atteint la méthode Task.Run()
, un autre thread est extrait du pool de threads et le code que nous avons transmis à Task.Run()
est exécuté. L'ancien thread, comme il sied à un thread décent, retourne dans le pool et attend qu'il soit à nouveau appelé pour effectuer le travail. Le nouveau thread exécute le code transmis, atteint l'opération synchrone, l'exécute de manière synchrone (attend que l'opération soit terminée) et va plus loin dans le code. En d'autres termes, l'opération est restée synchrone: nous utilisons, comme précédemment, le flux lors de l'exécution de l'opération synchrone. La seule différence est que nous avons passé du temps à changer de contexte lors de l'appel de Task.Run()
et du retour à ExecuteOperation()
. Tout est devenu un peu pire.
Il faut comprendre que malgré le fait que dans les lignes Inside after sleep: 3
et After: 3
nous voyons le même Id du flux, le contexte d'exécution est complètement différent à ces endroits. ASP.NET est simplement plus intelligent que nous et essaie d'économiser des ressources lors du changement de contexte du code à l'intérieur de Task.Run()
vers du code externe. Ici, il a décidé de ne pas modifier au moins le flux d'exécution.
Dans de tels cas, cela n'a aucun sens d'utiliser Task.Run()
. Au lieu de cela, Clary conseille de rendre toutes les opérations asynchrones, c'est-à-dire, dans notre cas, de remplacer Thread.Sleep(1000)
par Task.Delay(1000)
, mais cela, bien sûr, n'est pas toujours possible. Que faire dans les cas où nous utilisons des bibliothèques tierces que nous ne pouvons pas ou ne voulons pas réécrire et rendre asynchrones à la fin, mais pour une raison ou une autre, nous avons besoin de la méthode async? Il est préférable d'utiliser Task.FromResult()
pour Task.FromResult()
le résultat des méthodes de fournisseur dans Task. Bien sûr, cela ne rendra pas le code asynchrone, mais nous économiserons au moins sur le changement de contexte.
Pourquoi alors utiliser Task.Run ()? La réponse est simple: pour les opérations liées au processeur, lorsque vous devez maintenir la réactivité de l'interface utilisateur ou paralléliser les calculs. Il faut dire ici que les opérations liées au CPU sont de nature synchrone. C'est pour lancer des opérations synchrones dans un style asynchrone que Task.Run()
été inventé.
Utilisation abusive de l'async void
La possibilité d'écrire des méthodes asynchrones qui retournent
void
été ajoutée afin d'écrire des gestionnaires d'événements asynchrones. Voyons pourquoi ils peuvent prêter à confusion s'ils sont utilisés à d'autres fins:
- Vous ne pouvez pas attendre le résultat.
- La gestion des exceptions via try-catch n'est pas prise en charge.
- Il est impossible de combiner des appels via
Task.WhenAll()
, Task.WhenAny()
et d'autres méthodes similaires.
De toutes ces raisons, le point le plus intéressant est le traitement des exceptions. Le fait est que dans les méthodes asynchrones qui renvoient Task
ou Task<T>
, les exceptions sont interceptées et enveloppées dans un objet Task
, qui sera ensuite transmis à la méthode appelante. Dans son article MSDN, Clary écrit que puisqu'il n'y a pas de valeur de retour dans les méthodes async-void, il n'y a rien pour encapsuler les exceptions et elles sont levées directement dans le contexte de la synchronisation. Le résultat est une exception non gérée en raison de laquelle le processus se bloque, ayant le temps, peut-être, d'écrire une erreur sur la console. Vous pouvez obtenir et réserver ces exceptions en vous abonnant à l'événement AppDomain.UnhandledException
, mais vous ne pourrez plus arrêter le plantage du processus même dans le gestionnaire de cet événement. Ce comportement est typique du gestionnaire d'événements, mais pas de la méthode habituelle, à partir de laquelle nous nous attendons à une gestion standard des exceptions via try-catch.
Par exemple, si vous écrivez comme ceci dans une application ASP.NET Core, le processus est garanti de tomber:
public IActionResult ThrowInAsyncVoid() { ThrowAsynchronously(); return View(); } private async void ThrowAsynchronously() { throw new Exception("Obviously, something happened"); }
Mais cela vaut la peine de changer le type de retour de la méthode ThrowAsynchronously
en Task
(sans même ajouter le mot-clé ThrowAsynchronously
) et l'exception sera interceptée par le gestionnaire d'erreurs ASP.NET Core standard, et le processus continuera à vivre malgré l'exécution.
Soyez prudent avec les méthodes async-void - elles peuvent vous mettre dans le processus.
attendre en une seule ligne
Le dernier contre-modèle n'est pas aussi effrayant que les précédents. L'essentiel est qu'il n'a aucun sens d'utiliser async / wait dans des méthodes qui, par exemple, transmettent simplement le résultat d'une autre méthode async, à l'exception possible de l'utilisation de wait dans using .
Au lieu de ce code:
public async Task MyMethodAsync() { await Task.Delay(1000); }
il serait tout à fait possible (et de préférence) d'écrire:
public Task MyMethodAsync() { return Task.Delay(1000); }
Pourquoi ça marche? Parce que le mot clé wait peut être appliqué aux objets de type tâche, et non aux méthodes marquées avec le mot clé async. À son tour, le mot clé async indique simplement au compilateur que cette méthode doit être déployée sur une machine d'état, et toutes les valeurs retournées doivent être encapsulées dans une Task
(ou dans un autre objet de type tâche).
En d'autres termes, le résultat de la première version de la méthode est Task
, qui deviendra Completed
dès que l'attente de Task.Delay(1000)
se termine, et le résultat de la deuxième version de la méthode est Task
, renvoyé par Task.Delay(1000)
- Task.Delay(1000)
, qui deviendra Completed
dès 1000 millisecondes. .
Comme vous pouvez le voir, les deux versions sont équivalentes, mais en même temps, la première nécessite beaucoup plus de ressources pour créer un «kit carrosserie» asynchrone.
Alex Davis écrit que le coût de l'invocation directe de la méthode asynchrone peut être dix fois supérieur à celui de l'invocation de la méthode synchrone , donc il y a quelque chose à essayer.
UPD:Comme le soulignent à juste titre les commentaires, le fait de scinder asynchrone / attendre des méthodes à ligne unique entraîne des effets secondaires négatifs. Par exemple, lors du lancement d'une exception, la méthode qui lève Task vers le haut ne sera pas visible dans la pile. Par conséquent, la
suppression des valeurs par défaut n'est pas recommandée par défaut .
Message de Clary avec analyse.