C #: un cas d'utilisation pour n'importe quelle tâche

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.."; // 1 -- UI Thread var result = await _stockPrices.GetStockPricesForAsync("MSFT"); // 2 -- Usually non-UI Thread textBox.Text = "Result is: " + result; //3 -- Should be UI Thread } 

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?

 // UI code private void buttonOk_Click(object sender, EventArgs args) { textBox.Text = "Running.."; var result = _stockPrices.GetStockPricesForAsync("MSFT").Result; textBox.Text = "Result is: " + result; } // StockPrices.dll public Task<decimal> GetStockPricesForAsync(string symbol) { await Task.Yield(); return 42; } 

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:

 // UI code private void buttonOk_Click(object sender, EventArgs args) { textBox.Text = "Running.."; var result = _stockPrices.GetStockPricesForAsync("MSFT").Result; textBox.Text = "Result is: " + result; } // StockPrices.dll public Task<decimal> GetStockPricesForAsync(string symbol) { // We know that the initialization step is very fast, // and completes synchronously in most cases, // let's wait for the result synchronously for "performance reasons". InitializeIfNeededAsync().Wait(); return Task.FromResult((decimal)42); } // StockPrices.dll private async Task InitializeIfNeededAsync() => await Task.Delay(1); 

Ce code provoque à nouveau un blocage. Comment le résoudre:

  • Vous ne devez pas bloquer le code asynchrone avec Task.Wait() ou Task.Result et
  • 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() { // Initialize the cache field first await _cache.InitializeAsync().ConfigureAwait(false); // Do some work await Task.Delay(1); } 

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; } // StockPrices.dll public async Task<decimal> GetStockPricesForAsync(string symbol) { // The rest of the method is guarantee won't have a current sync context. await Awaiters.DetachCurrentSyncContext(); // We can wait synchronously here and we won't have a deadlock. InitializeIfNeededAsync().Wait(); return 42; } 

Awaiters.DetachCurrentSyncContext renvoie l'objet d'attente personnalisé suivant:

 public struct DetachSynchronizationContextAwaiter : ICriticalNotifyCompletion { /// <summary> /// Returns true if a current synchronization context is null. /// It means that the continuation is called only when a current context /// is presented. /// </summary> public bool IsCompleted => SynchronizationContext.Current == null; public void OnCompleted(Action continuation) { ThreadPool.QueueUserWorkItem(state => continuation()); } public void UnsafeOnCompleted(Action continuation) { ThreadPool.UnsafeQueueUserWorkItem(state => continuation(), null); } public void GetResult() { } public DetachSynchronizationContextAwaiter GetAwaiter() => this; } public static class Awaiters { public static DetachSynchronizationContextAwaiter DetachCurrentSyncContext() { return new DetachSynchronizationContextAwaiter(); } } 

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 par Awaiters.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 ConfigureAwait avec 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 ());

 // Case 1 try { await mayFail; } catch (ArgumentException e) { // Handle the error } // Case 2 try { mayFail.Wait(); } catch (ArgumentException e) { // Handle the error } 

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()); // await will rethrow the first exception await Task.WhenAll(task1, task2); } catch (Exception e) { // ArgumentNullException. The second error is lost! Console.WriteLine(e.GetType()); } 

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:

  1. afficher manuellement les tâches si elles y ont accès, ou
  2. 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()); // t.Result forces TPL to wrap the exception into AggregateException await Task.WhenAll(task1, task2).ContinueWith(t => t.Result); } catch(Exception e) { // AggregateException Console.WriteLine(e.GetType()); } 

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) { // Calls 'provider' N times and calls 'onError' in case of an error. } public async Task<string> AccidentalAsyncVoid(string fileName) { return await ActionWithRetry( provider: () => { return File.ReadAllTextAsync(fileName); }, // Can you spot the issue? onError: async e => { await File.WriteAllTextAsync(errorLogFile, e.ToString()); }); } 

À 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.

Source: https://habr.com/ru/post/fr421245/


All Articles