Bonjour, Habr! Nous continuons à parler de programmation asynchrone en C #. Aujourd'hui, nous parlerons d'un cas d'utilisation unique ou d'un scénario spécifique à l'utilisateur adapté à toutes les tâches dans le cadre de la programmation asynchrone. Nous aborderons les sujets de la synchronisation, des blocages, des paramètres de l'opérateur, de la gestion des exceptions et bien plus encore. Rejoignez-nous maintenant!

Articles connexes précédents
Presque tout comportement non standard des méthodes asynchrones en C # peut être expliqué sur la base d'un scénario utilisateur: la conversion d'un code synchrone existant en asynchrone doit être aussi simple que possible. Vous devez pouvoir ajouter le mot-clé async avant le type de retour de la méthode, ajouter le suffixe Async au nom de cette méthode et ajouter le mot-clé wait ici et dans la zone de texte de la méthode pour obtenir une méthode asynchrone entièrement fonctionnelle.

Un scénario «simple» modifie considérablement de nombreux aspects du comportement des méthodes asynchrones: de la planification de la durée d'une tâche à la gestion des exceptions. Le script semble convaincant et significatif, mais dans son contexte, la simplicité des méthodes asynchrones devient très trompeuse.
Contexte de synchronisation
Le développement de l'interface utilisateur (UI) est un domaine dans lequel le scénario ci-dessus est particulièrement important. En raison de longues opérations dans le thread d'interface utilisateur, le temps de réponse des applications augmente, auquel cas la programmation asynchrone a toujours été considérée comme un outil très efficace.
private async void buttonOk_ClickAsync(object sender, EventArgs args) { textBox.Text = "Running..";  
Le code semble très simple, mais il y a un problème. Il existe des restrictions pour la plupart des interfaces utilisateur: les éléments de l'interface utilisateur ne peuvent être modifiés que par des threads spéciaux. Autrement dit, à la ligne 3, une erreur se produit si la durée de la tâche est planifiée dans le thread à partir du pool de threads. Heureusement, ce problème est connu depuis longtemps et le concept de 
contexte de synchronisation est apparu dans la version du .NET Framework 2.0.
Chaque interface utilisateur fournit des utilitaires spéciaux pour le marshaling des tâches dans un ou plusieurs threads d'interface utilisateur spécialisés. Windows Forms utilise la méthode 
Control.Invoke , WPF 
Control.Invoke méthode Dispatcher.Invoke, d'autres systèmes peuvent accéder à d'autres méthodes. Les schémas utilisés dans tous ces cas sont largement similaires, mais diffèrent en détail. Le contexte de synchronisation vous permet de faire abstraction des différences en fournissant une API pour exécuter le code dans un contexte «spécial» qui permet le traitement des détails mineurs par des types dérivés tels que 
WindowsFormsSynchronizationContext , 
DispatcherSynchronizationContext , etc.
Pour résoudre le problème de l'affinité des threads, les programmeurs C # ont décidé d'introduire le contexte de synchronisation actuel au stade initial de la mise en œuvre des méthodes asynchrones et de planifier toutes les opérations suivantes dans ce contexte. Désormais, chacun des blocs entre les instructions en attente est exécuté dans le thread d'interface utilisateur, ce qui permet d'implémenter le script principal. Cependant, cette solution a soulevé un certain nombre de nouveaux problèmes.
Deadlocks
Regardons un petit morceau de code relativement simple. Y a-t-il des problèmes ici?
 
Ce code provoque un 
blocage . Le thread d'interface utilisateur démarre une opération asynchrone et attend le résultat de manière synchrone. Toutefois, la méthode asynchrone ne peut pas être terminée car la deuxième ligne de 
GetStockPricesForAsync doit être exécutée dans le thread d'interface utilisateur qui provoque le blocage.
Vous objecterez que ce problème est assez facile à résoudre. Oui en effet. Vous devez interdire tous les appels à la 
Task.Wait Task.Result ou 
Task.Wait partir du code d'interface utilisateur, cependant, le problème peut toujours se produire si le composant utilisé par ce code attend le résultat de l'opération utilisateur de manière synchrone:
 
Ce code provoque à nouveau un blocage. Comment le résoudre:
- Vous ne devez pas bloquer le code asynchrone avec Task.Wait()ouTask.Resultet
- utilisez ConfigureAwait(false)dans le code de la bibliothèque.
La signification de la première recommandation est claire, et la deuxième, nous l'expliquerons ci-dessous.
Configuration des instructions d'attente
Il existe deux raisons pour lesquelles un blocage se produit dans le dernier exemple: 
Task.Wait() dans 
GetStockPricesForAsync et l'utilisation indirecte du contexte de synchronisation dans les étapes suivantes de InitializeIfNeededAsync. Bien que les programmeurs C # ne recommandent pas de bloquer les appels aux méthodes asynchrones, il est évident que dans la plupart des cas, ce blocage est toujours utilisé. Les programmeurs C # offrent la solution suivante à un problème de blocage: 
Task.ConfigureAwait(continueOnCapturedContext:false) .
Malgré l'apparence étrange (si un appel de méthode est exécuté sans argument nommé, cela ne signifie rien du tout), cette solution remplit sa fonction: elle fournit une poursuite forcée de l'exécution sans contexte de synchronisation.
 public Task<decimal> GetStockPricesForAsync(string symbol) { InitializeIfNeededAsync().Wait(); return Task.FromResult((decimal)42); } private async Task InitializeIfNeededAsync() => await Task.Delay(1).ConfigureAwait(false); 
Dans ce cas, la poursuite de la 
Task.Delay(1 ) (voici l'instruction vide) est planifiée dans le thread du pool de threads, et non dans le thread de l'interface utilisateur, ce qui élimine le blocage.
Désactiver le contexte de synchronisation
Je sais que 
ConfigureAwait résout réellement ce problème, mais il apparaît beaucoup plus. Voici un petit exemple:
 public Task<decimal> GetStockPricesForAsync(string symbol) { InitializeIfNeededAsync().Wait(); return Task.FromResult((decimal)42); } private async Task InitializeIfNeededAsync() {  
Voyez-vous le problème? Nous avons utilisé 
ConfigureAwait(false) , donc tout devrait bien se passer. Mais pas un fait.
ConfigureAwait(false) renvoie un objet 
ConfiguredTaskAwaitable attente personnalisé, et nous savons qu'il n'est utilisé que si la tâche ne se termine pas de manière synchrone. Autrement dit, si 
_cache.InitializeAsync() termine de manière synchrone, un blocage est toujours possible.
Pour éliminer les blocages, toutes les tâches en attente de fin doivent être «décorées» avec un appel à la méthode 
ConfigureAwait(false) . Tout cela agace et génère des erreurs.
Vous pouvez également utiliser l'objet personnalisé waiter dans toutes les méthodes publiques pour désactiver le contexte de synchronisation dans la méthode asynchrone:
 private void buttonOk_Click(object sender, EventArgs args) { textBox.Text = "Running.."; var result = _stockPrices.GetStockPricesForAsync("MSFT").Result; textBox.Text = "Result is: " + result; }  
Awaiters.DetachCurrentSyncContext renvoie l'objet d'attente personnalisé suivant:
 public struct DetachSynchronizationContextAwaiter : ICriticalNotifyCompletion {  
DetachSynchronizationContextAwaiter effectue les opérations suivantes: la méthode async fonctionne avec un contexte de synchronisation différent de zéro. Mais si la méthode async fonctionne sans contexte de synchronisation, la propriété 
IsCompleted renvoie true et la poursuite de la méthode est effectuée de manière synchrone.
Cela signifie que les données de service sont proches de zéro lorsque la méthode asynchrone est exécutée à partir d'un thread dans le pool de threads et que le paiement est effectué une fois pour le transfert de l'exécution du thread de l'interface utilisateur vers le thread du pool de threads.
D'autres avantages de cette approche sont énumérés ci-dessous.
- La probabilité d'erreur est réduite. ConfigureAwait(false)ne fonctionne que s'il est appliqué à toutes les tâches en attente de fin. Il vaut la peine d'oublier au moins une chose - et une impasse peut se produire. Dans le cas d'un objet attendant personnalisé, n'oubliez pas que toutes les méthodes de bibliothèque publique doivent commencer parAwaiters.DetachCurrentSyncContext(). Des erreurs sont possibles ici, mais leur probabilité est beaucoup plus faible.
- Le code résultant est plus déclaratif et clair. La méthode ConfigureAwaitavec plusieurs appels me semble moins lisible (en raison d'éléments supplémentaires) et pas assez informative pour les débutants.
Gestion des exceptions
Quelle est la différence entre ces deux options:
Task mayFail = Task.FromException (new ArgumentNullException ());
 
Dans le premier cas, tout répond aux attentes - le traitement des erreurs est effectué, mais dans le second cas, cela ne se produit pas. La bibliothèque de tâches parallèles TPL est conçue pour la programmation asynchrone et parallèle, et la tâche / tâche peut représenter le résultat de plusieurs opérations. C'est pourquoi 
Task.Result et 
Task.Wait() toujours une 
AggregateException , qui peut contenir plusieurs erreurs.
Cependant, notre scénario principal change tout: l'utilisateur devrait pouvoir ajouter l'opérateur asynchrone / attendre sans toucher à la logique de gestion des erreurs. Autrement dit, l'instruction d'attente doit être différente de 
Task.Result / 
Task.Wait() : elle doit supprimer l'encapsuleur d'une exception dans l'instance 
AggregateException . Aujourd'hui, nous allons sélectionner la première exception.
Tout va bien si toutes les méthodes basées sur Task sont asynchrones et que les calculs parallèles ne sont pas utilisés pour effectuer des tâches. Mais dans certains cas, tout est différent:
 try { Task<int> task1 = Task.FromException<int>(new ArgumentNullException()); Task<int> task2 = Task.FromException<int>(new InvalidOperationException());  
Task.WhenAll renvoie une tâche avec deux erreurs, cependant, l'instruction d'attente récupère et remplit uniquement la première.
Il existe deux façons de résoudre ce problème:
- afficher manuellement les tâches si elles y ont accès, ou
- configurez la bibliothèque TPL pour forcer l'exception à être encapsulée dans une autre AggregateException.
 try { Task<int> task1 = Task.FromException<int>(new ArgumentNullException()); Task<int> task2 = Task.FromException<int>(new InvalidOperationException());  
Méthode Async void
La méthode basée sur les tâches renvoie un jeton qui peut être utilisé pour traiter les résultats à l'avenir. Si la tâche est perdue, le jeton devient inaccessible à la lecture par le code utilisateur. Une opération asynchrone qui renvoie la méthode void génère une erreur qui ne peut pas être gérée dans le code utilisateur. En ce sens, les jetons sont inutiles et même dangereux - maintenant nous le verrons. Cependant, notre scénario principal suppose leur utilisation obligatoire:
 private async void buttonOk_ClickAsync(object sender, EventArgs args) { textBox.Text = "Running.."; var result = await _stockPrices.GetStockPricesForAsync("MSFT"); textBox.Text = "Result is: " + result; } 
Mais que se passe- 
GetStockPricesForAsync si 
GetStockPricesForAsync génère une erreur? Une exception de méthode async void non gérée est marshalée dans le contexte de synchronisation actuel, déclenchant le même comportement que pour le code synchrone (pour plus d'informations, consultez la 
méthode ThrowAsync sur la page Web 
AsyncMethodBuilder.cs ). Sur Windows Forms, une exception non gérée dans le gestionnaire d'événements déclenche l'événement 
Application.ThreadException , pour WPF, l'événement 
Application.DispatcherUnhandledException déclenche, etc.
Que faire si la méthode async void n'obtient pas le contexte de synchronisation? Dans ce cas, une exception non gérée provoque un blocage fatal de l'application. Il ne déclenchera pas l'événement [ 
TaskScheduler.UnobservedTaskException ] en cours de restauration, mais déclenchera l'événement 
AppDomain.UnhandledException qui ne sera pas restauré, puis fermera l'application. Cela se produit intentionnellement, et c'est exactement le résultat dont nous avons besoin.
Voyons maintenant une autre méthode bien connue: utiliser des méthodes void asynchrones uniquement pour les gestionnaires d'événements de l'interface utilisateur.
Malheureusement, la méthode asynch void est facile à appeler par accident.
 public static Task<T> ActionWithRetry<T>(Func<Task<T>> provider, Action<Exception> onError) {  
À première vue, l'expression lambda est difficile à dire si la fonction est une méthode basée sur des tâches ou une méthode async void, et donc une erreur peut se glisser dans votre base de code, malgré la vérification la plus approfondie.
Conclusion
De nombreux aspects de la programmation asynchrone en C # ont été influencés par un seul scénario utilisateur - il suffit de convertir le code synchrone d'une application d'interface utilisateur existante en asynchrone:
- L'exécution ultérieure de méthodes asynchrones est planifiée dans le contexte de synchronisation résultant, ce qui peut entraîner des blocages.
- Pour les éviter, il est nécessaire de placer des appels ConfigureAwait(false)partout dans le code de la bibliothèque asynchrone.
- attendre la tâche; produit la première erreur, ce qui complique la création d'une exception de traitement pour la programmation parallèle.
- Des méthodes async void ont été introduites pour gérer les événements de l'interface utilisateur, mais elles sont faciles à exécuter par accident, ce qui entraînera le blocage de l'application si une exception est levée.
Le fromage gratuit se produit uniquement dans une souricière. La facilité d'utilisation peut parfois entraîner de grandes difficultés dans d'autres domaines. Si vous connaissez l'histoire de la programmation asynchrone en C #, le comportement le plus étrange ne semble plus si étrange et la probabilité d'erreurs dans le code asynchrone est considérablement réduite.