Dans ma pratique, je trouve souvent, dans un environnement différent , du code comme celui ci-dessous:
[1] var x = FooWithResultAsync(/*...*/).Result; // [2] FooAsync(/*...*/).Wait(); // [3] FooAsync(/*...*/).GetAwaiter().GetResult(); // [4] FooAsync(/*...*/) .ConfigureAwait(false) .GetAwaiter() .GetResult(); // [5] await FooAsync(/*...*/).ConfigureAwait(false) // [6] await FooAsync(/*...*/)
De la communication avec les auteurs de ces lignes, il est devenu clair qu'elles sont toutes divisées en trois groupes:
- Le premier groupe est celui qui ne sait rien des problèmes possibles avec l'appel de
Result/Wait/GetResult
. Les exemples (1-3) et parfois (6) sont typiques pour les programmeurs de ce groupe; - Le deuxième groupe comprend des programmeurs qui sont conscients des problèmes possibles, mais ils ne connaissent pas les causes de leur occurrence. Les développeurs de ce groupe, d'une part, essaient d'éviter les lignes comme (1-3 et 6), mais, d'autre part, abusent du code comme (4-5);
- Le troisième groupe, d'après mon expérience le plus petit, est constitué par les programmeurs qui savent comment fonctionne le code (1-6), et peuvent donc faire un choix éclairé.
Le risque est-il possible, et quelle est sa taille, lors de l'utilisation du code, comme dans les exemples ci-dessus, dépend, comme je l'ai noté précédemment, de l' environnement .

Les risques et leurs causes
Les exemples (1-6) sont divisés en deux groupes. Le premier groupe est le code qui bloque le thread appelant. Ce groupe comprend (1-4).
Bloquer un thread est le plus souvent une mauvaise idée. Pourquoi? Pour simplifier, nous supposons que tous les threads sont alloués à partir d'un pool de threads. Si le programme a un verrou, cela peut conduire à la sélection de tous les threads du pool. Dans le meilleur des cas, cela ralentira le programme et conduira à une utilisation inefficace des ressources. Dans le pire des cas, cela peut entraîner un blocage, lorsqu'un thread supplémentaire est nécessaire pour terminer une tâche, mais que le pool ne peut pas l'allouer.
Ainsi, lorsqu'un développeur écrit du code comme (1-4), il doit réfléchir à la probabilité de la situation décrite ci-dessus.
Mais les choses empirent lorsque nous travaillons dans un environnement dans lequel il existe un contexte de synchronisation différent de la norme. S'il existe un contexte de synchronisation spécial , le blocage du thread appelant augmente la probabilité qu'un blocage se produise plusieurs fois. Ainsi, le code des exemples (1-3), s'il est exécuté dans le thread d'interface utilisateur WinForms, est presque garanti de créer un blocage. J'écris "pratiquement" parce que il y a une option quand ce n'est pas le cas, mais plus à ce sujet plus tard. L'ajout de ConfigureAwait(false)
, comme dans (4), ne donnera pas une garantie à 100% de protection contre l'impasse. Voici un exemple pour le confirmer:
[7] // / . async Task FooAsync() { // Delay . . await Task.Delay(5000); // RestPartOfMethodCode(); } // "" , , WinForms . private void button1_Click(object sender, EventArgs e) { FooAsync() .ConfigureAwait(false) .GetAwaiter() .GetResult(); button1.Text = "new text"; }
L'article "Parallel Computing - Tout est dans le SynchronizationContext" fournit des informations sur différents contextes de synchronisation.
Afin de comprendre la cause du blocage, vous devez analyser le code de la machine d'état dans laquelle l'appel à la méthode async est converti, puis le code des classes MS. Un Async Await et l' article Generated StateMachine fournissent un exemple d'une telle machine d'état.
Je ne donnerai pas le code source complet généré par exemple (7), l'automate, je ne montrerai que les lignes importantes pour une analyse plus approfondie:
// MoveNext. //... // taskAwaiter . taskAwaiter = Task.Delay(5000).GetAwaiter(); if(tasAwaiter.IsCompleted != true) { _awaiter = taskAwaiter; _nextState = ...; _builder.AwaitUnsafeOnCompleted<TaskAwaiter, ThisStateMachine>(ref taskAwaiter, ref this); return; }
La branche if
est exécutée si l'appel asynchrone ( Delay
) n'est pas encore terminé et, par conséquent, le thread actuel peut être libéré.
Veuillez noter que dans AwaitUnsafeOnCompleted
, taskAwaiter est reçu d'un appel asynchrone interne (par rapport à FooAsync
) ( Delay
).
Si vous plongez dans la jungle des sources MS qui sont cachées derrière l'appel AwaitUnsafeOnCompleted
, nous arriverons finalement à la classe SynchronizationContextAwaitTaskContinuation et à sa classe de base AwaitTaskContinuation , où se trouve la réponse à la question.
Le code de ces classes et des classes connexes est assez déroutant, donc, pour faciliter la perception, je me permets d'écrire un "analogique" très simplifié de ce que l'exemple (7) devient, mais sans machine à états, et en termes de TPL:
[8] Task FooAsync() { // methodCompleted , , // , " ". // , methodCompleted.WaitOne() , // SetResult AsyncTaskMethodBuilder, // . var methodCompleted = new AutoResetEvent(false); SynchronizationContext current = SynchronizationContext.Current; return Task.Delay(5000).ContinueWith( t=> { if(current == null) { RestPartOfMethodCode(methodCompleted); } else { current.Post(state=>RestPartOfMethodCode(methodCompleted), null); methodCompleted.WaitOne(); } }, TaskScheduler.Current); } // // void RestPartOfMethodCode(AutoResetEvent methodCompleted) // { // FooAsync. // methodCompleted.Set(); // }
Dans l'exemple (8), il est important de faire attention au fait que s'il existe un contexte de synchronisation, tout le code de la méthode asynchrone qui vient après la fin de l'appel asynchrone interne est exécuté via ce contexte (appel current.Post(...)
). Ce fait est à l'origine des blocages. Par exemple, si nous parlons d'une application WinForms, le contexte de synchronisation qu'elle contient est associé au flux d'interface utilisateur. Si le thread d'interface utilisateur est bloqué, dans l'exemple (7), cela se produit via un appel à .GetResult()
, le reste du code de la méthode asynchrone ne peut pas être exécuté, ce qui signifie que la méthode asynchrone ne peut pas se terminer et ne peut pas libérer le thread d'interface utilisateur, qui est impasse.
Dans l'exemple (7), l'appel à FooAsync
été configuré via ConfigureAwait(false)
, mais cela n'a pas aidé. Le fait est que vous devez configurer exactement l'objet d'attente qui sera transmis à AwaitUnsafeOnCompleted
, dans notre exemple, il s'agit de l'objet d'attente de l'appel de Delay
. En d'autres termes, dans ce cas, appeler ConfigureAwait(false)
dans le code client n'a pas de sens. Vous pouvez résoudre le problème si le développeur de la méthode FooAsync
modifie comme suit:
[9] async Task FooAsync() { await Task.Delay(5000).ConfigureAwait(false); // RestPartOfMethodCode(); } private void button1_Click(object sender, EventArgs e) { FooAsync().GetAwaiter().GetResult(); button1.Text = "new text"; }
Ci-dessus, nous avons examiné les risques qui surviennent avec le code du premier groupe - le code avec blocage (exemples 1-4). Maintenant sur le deuxième groupe (exemples 5 et 6) - un code sans verrous. Dans ce cas, la question est: quand l'appel à ConfigureAwait(false)
justifié? Lors de l'analyse de l'exemple (7), nous avons déjà découvert que nous devons configurer l'objet en attente sur la base duquel la poursuite de l'exécution sera construite. C'est-à-dire la configuration est requise (si vous prenez cette décision) uniquement pour les appels asynchrones internes .
Qui est à blâmer?
Comme toujours, la bonne réponse est «tout». Commençons par les programmeurs de MS. D'une part, les développeurs de Microsoft ont décidé qu'en présence d'un contexte de synchronisation, un travail devait être effectué à travers celui-ci. Et cela est logique, sinon pourquoi est-il toujours nécessaire. Et, comme je le crois, ils s'attendaient à ce que les développeurs du code "client" ne bloquent pas le thread principal, surtout si le contexte de synchronisation y est lié. D'un autre côté, ils ont donné un outil très simple pour "se tirer une balle dans le pied" - c'est trop simple et pratique pour obtenir le résultat en bloquant .Result/.GetResult
, ou bloquer le flux, en attendant la fin de l'appel, via .Wait
. C'est-à-dire Les développeurs de MS ont permis que l'utilisation "incorrecte" (ou dangereuse) de leurs bibliothèques ne pose aucun problème.
Mais il y a aussi le blâme sur les développeurs du code "client". Elle consiste en ce que, souvent, les développeurs n'essaient pas de comprendre leur outil et négligent les avertissements. Et c'est un chemin direct vers les erreurs.
Que faire
Ci-dessous, je donne mes recommandations.
Pour les développeurs de code client
- Faites de votre mieux pour éviter le blocage. En d'autres termes, ne mélangez pas le code synchrone et asynchrone sans besoin particulier.
- Si vous devez verrouiller, déterminez dans quel environnement le code est exécuté:
- Y a-t-il un contexte de synchronisation? Si oui, lequel? Quelles caractéristiques crée-t-il dans son travail?
- S'il n'y a pas de contexte de synchronisation, alors: Quelle sera la charge? Quelle est la probabilité que votre bloc entraîne une "fuite" de threads dans le pool? Le nombre de threads créés au démarrage sera-t-il suffisant par défaut, ou devrais-je en allouer plus?
- Si le code est asynchrone, devez-vous configurer l'appel asynchrone via
ConfigureAwait
?
Prenez une décision basée sur toutes les informations reçues. Vous devrez peut-être repenser votre approche de mise en œuvre. Peut-être que ConfigureAwait
vous aidera, ou peut-être que vous n'en avez pas besoin.
Pour les développeurs de bibliothèques
- Si vous pensez que votre code peut être appelé à partir de "synchronous", assurez-vous d'implémenter une API synchrone. Il doit être vraiment synchrone, c'est-à-dire Vous devez utiliser l'API synchrone des bibliothèques tierces.
ConfigureAwait(true / false)
.
Ici, de mon point de vue, une approche plus subtile est nécessaire que d'habitude recommandée. De nombreux articles indiquent que dans le code de bibliothèque, tous les appels asynchrones doivent être configurés via ConfigureAwait(false)
. Je ne peux pas être d'accord avec cela. Du point de vue des auteurs, les collègues de Microsoft ont peut-être pris la mauvaise décision lors du choix du comportement "par défaut" par rapport à l'utilisation du contexte de synchronisation. Mais ils (MS) ont néanmoins laissé aux développeurs du code "client" la possibilité de changer ce comportement. La stratégie, lorsque le code de bibliothèque est entièrement couvert par ConfigureAwait(false)
, modifie le comportement par défaut et, plus important encore, cette approche prive les développeurs du code "client" de choix.
Mon option consiste à, lors de la mise en œuvre de l'API asynchrone, ajouter deux paramètres d'entrée supplémentaires à chaque méthode d'API: CancellationToken token
et bool continueOnCapturedContext
. Et implémentez le code comme suit:
public async Task<string> FooAsync( /* */, CancellationToken token, bool continueOnCapturedContext) { // ... await Task.Delay(30, token).ConfigureAwait(continueOnCapturedContext); // ... return result; }
Le premier paramètre, le token
, sert, comme vous le savez, à la possibilité d'une annulation coordonnée (les développeurs de bibliothèques négligent parfois cette fonctionnalité). Le second, continueOnCapturedContext
- vous permet de configurer l'interaction avec le contexte de synchronisation des appels asynchrones internes.
Dans le même temps, si la méthode API asynchrone fait elle-même partie d'une autre méthode asynchrone, le code "client" pourra déterminer comment il doit interagir avec le contexte de synchronisation:
// : async Task ClientFoo() { // "" ClientFoo , // FooAsync . await FooAsync( /* */, ancellationToken.None, false); // . await FooAsync( /* */, ancellationToken.None, false).ConfigureAwait(false); //... } // , . private void button1_Click(object sender, EventArgs e) { FooAsync( /* */, _source.Token, false).GetAwaiter().GetResult(); button1.Text = "new text"; }
En conclusion
La principale conclusion de ce qui précède est les trois réflexions suivantes:
- Les verrous sont le plus souvent la racine de tout mal. C'est la présence de verrous qui peut conduire, dans le meilleur des cas, à une dégradation des performances et à une utilisation inefficace des ressources, au pire - à un blocage. Avant d'utiliser des verrous, demandez-vous si cela est nécessaire? Il existe peut-être un autre moyen de synchronisation acceptable dans votre cas;
- Apprenez l'outil avec lequel vous travaillez;
- Si vous concevez des bibliothèques, essayez de vous assurer que leur utilisation correcte est facile, presque intuitive et que la mauvaise est lourde de complexité.
J'ai essayé le plus simplement possible d'expliquer les risques associés à l'async / attente et les raisons de leur apparition. Et aussi, présenté ma vision de la résolution de ces problèmes. J'espère que j'ai réussi, et le matériel sera utile au lecteur. Afin de mieux comprendre comment tout fonctionne réellement, vous devez bien sûr vous référer à la source. Cela peut être fait via les référentiels MS sur GitHub ou, encore plus commodément, via le site Web MS lui-même.
PS Je vous serais reconnaissant de critiques constructives.
