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()
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() {
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 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 ());
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.