Si je comprends bien le yield
mot - clé, s'il est utilisé à l'intérieur d'un bloc d'itérateur, il renvoie le flux de contrôle au code appelant, et lorsque l'itérateur est à nouveau appelé, il reprend là où il s'est arrêté.
En outre, await
non seulement attend l'appelé, mais il renvoie le contrôle à l'appelant, uniquement pour reprendre là où il s'était arrêté lorsque l'appelant awaits
la méthode.
En d'autres termes - il n'y a pas de fil , et la "concurrence" d'async et d'attente est une illusion causée par un flux de contrôle intelligent, dont les détails sont cachés par la syntaxe.
Maintenant, je suis un ancien programmeur d'assemblage et je suis très familier avec les pointeurs d'instructions, les piles, etc. et je comprends comment fonctionnent les flux normaux de contrôle (sous-programme, récursivité, boucles, branches). Mais ces nouvelles constructions - je ne les comprends pas.
Quand un await
est atteint, comment le runtime sait-il quel morceau de code doit être exécuté ensuite? Comment sait-il quand il peut reprendre là où il s'est arrêté et comment se souvient-il où? Qu'arrive-t-il à la pile d'appels actuelle, est-elle sauvegardée d'une manière ou d'une autre? Que faire si la méthode appelante effectue d'autres appels de méthode avant elleawait
soit - pourquoi la pile n'est-elle pas écrasée? Et comment diable le runtime fonctionnerait-il à travers tout cela dans le cas d'une exception et d'une pile se dérouler?
Quand yield
est atteint, comment le runtime garde-t-il une trace du point où les choses doivent être ramassées? Comment l'état de l'itérateur est-il préservé?
la source
Réponses:
Je répondrai à vos questions spécifiques ci-dessous, mais vous feriez probablement bien de simplement lire mes articles détaillés sur la façon dont nous avons conçu le rendement et l'attente.
https://blogs.msdn.microsoft.com/ericlippert/tag/continuation-passing-style/
https://blogs.msdn.microsoft.com/ericlippert/tag/iterators/
https://blogs.msdn.microsoft.com/ericlippert/tag/async/
Certains de ces articles sont désormais obsolètes; le code généré est différent à bien des égards. Mais ceux-ci vous donneront certainement une idée de son fonctionnement.
De plus, si vous ne comprenez pas comment les lambdas sont générées en tant que classes de fermeture, comprenez-le d' abord . Vous ne ferez pas des têtes ou des queues d'async si vous n'avez pas de lambdas vers le bas.
await
est généré comme:C'est fondamentalement ça. Attendre est juste un retour de fantaisie.
Eh bien, comment faites-vous cela sans attendre? Lorsque la méthode foo appelle method bar, nous nous rappelons comment revenir au milieu de foo, avec tous les locaux de l'activation de foo intacts, peu importe ce que fait la barre.
Vous savez comment cela se fait en assembleur. Un enregistrement d'activation pour foo est poussé sur la pile; il contient les valeurs des habitants. Au moment de l'appel, l'adresse de retour dans foo est poussée sur la pile. Lorsque la barre est terminée, le pointeur de pile et le pointeur d'instruction sont réinitialisés là où ils doivent être et foo continue à partir de là où il s'était arrêté.
La suite d'une attente est exactement la même, sauf que l'enregistrement est placé sur le tas pour la raison évidente que la séquence d'activations ne forme pas une pile .
Le délégué qui attend donne comme suite à la tâche contient (1) un nombre qui est l'entrée d'une table de recherche qui donne le pointeur d'instruction que vous devez exécuter ensuite, et (2) toutes les valeurs des locaux et des temporaires.
Il y a du matériel supplémentaire là-dedans; par exemple, dans .NET, il est illégal de créer une branche au milieu d'un bloc try, vous ne pouvez donc pas simplement coller l'adresse du code à l'intérieur d'un bloc try dans la table. Mais ce sont des détails de comptabilité. Conceptuellement, l'enregistrement d'activation est simplement déplacé sur le tas.
Les informations pertinentes dans l'enregistrement d'activation actuel ne sont jamais placées sur la pile en premier lieu; il est alloué du tas dès le départ. (Eh bien, les paramètres formels sont normalement passés sur la pile ou dans des registres, puis copiés dans un emplacement de tas lorsque la méthode commence.)
Les enregistrements d'activation des appelants ne sont pas stockés; l'attente va probablement leur revenir, rappelez-vous, donc ils seront traités normalement.
Notez qu'il s'agit d'une différence significative entre le style de passage de continuation simplifié de await et les véritables structures d'appel avec continuation en cours que vous voyez dans des langages comme Scheme. Dans ces langues, toute la suite, y compris la continuation vers les appelants, est capturée par call-cc .
Ces appels de méthode retournent, et donc leurs enregistrements d'activation ne sont plus sur la pile au moment de l'attente.
Dans le cas d'une exception non interceptée, l'exception est interceptée, stockée dans la tâche et relancée lorsque le résultat de la tâche est récupéré.
Vous vous souvenez de toute cette comptabilité que j'ai mentionnée auparavant? Obtenir une sémantique d'exception correcte était une énorme douleur, laissez-moi vous dire.
De la même façon. L'état des locaux est déplacé sur le tas, et un nombre représentant l'instruction à laquelle
MoveNext
doit reprendre la prochaine fois qu'elle est appelée est stocké avec les locaux.Et encore une fois, il y a un tas d'équipement dans un bloc d'itérateur pour s'assurer que les exceptions sont gérées correctement.
la source
yield
est le plus facile des deux, alors examinons-le.Disons que nous avons:
Cela est compilé un peu comme si nous avions écrit:
Donc, pas aussi efficace qu'une implémentation manuscrite de
IEnumerable<int>
etIEnumerator<int>
(par exemple, nous ne gaspillerions probablement pas d'avoir un séparé_state
,_i
et_current
dans ce cas) mais pas mal (l'astuce de se réutiliser quand il est sûr de le faire plutôt que de créer un nouveau object is good), et extensible pour gérer desyield
méthodes très compliquées .Et bien sûr depuis
Est le même que:
Ensuite, le généré
MoveNext()
est appelé à plusieurs reprises.Le
async
cas est à peu près le même principe, mais avec un peu de complexité supplémentaire. Pour réutiliser un exemple d' un autre code de réponse comme:Produit du code comme:
C'est plus compliqué, mais un principe de base très similaire. La principale complication supplémentaire est que maintenant
GetAwaiter()
est utilisé. Si un momentawaiter.IsCompleted
est coché, il retournetrue
parce que la tâcheawait
ed est déjà terminée (par exemple, les cas où elle pourrait retourner de manière synchrone) alors la méthode continue de se déplacer à travers les états, mais sinon elle se configure comme un rappel du serveur.Ce qui se passe avec cela dépend de l'attendeur, en termes de ce qui déclenche le rappel (par exemple, achèvement d'E / S asynchrone, une tâche s'exécutant sur un thread se terminant) et des conditions requises pour le marshalling vers un thread particulier ou l'exécution sur un thread du pool de threads , quel contexte de l'appel d'origine peut être nécessaire ou non, etc. Quoi qu'il en soit, quelque chose dans ce serveur appellera le
MoveNext
et il continuera soit avec le travail suivant (jusqu'au suivantawait
), soit se terminera et retournera, auquel casTask
celui qu'il implémentera sera terminé.la source
yield
à roulé à la main quand il y a un avantage à le faire (généralement en tant qu'optimisation, mais voulant m'assurer que le point de départ est proche de celui généré par le compilateur donc rien n'est désoptimisé par de mauvaises hypothèses). La seconde a été utilisée pour la première fois dans une autre réponse et il y avait quelques lacunes dans mes propres connaissances à l'époque, alors j'ai profité de les remplir tout en fournissant cette réponse en décompilant manuellement le code.Il y a déjà une tonne de bonnes réponses ici; Je vais juste partager quelques points de vue qui peuvent aider à former un modèle mental.
Tout d'abord, une
async
méthode est divisée en plusieurs morceaux par le compilateur; lesawait
expressions sont les points de fracture. (Ceci est facile à concevoir pour des méthodes simples; les méthodes plus complexes avec des boucles et la gestion des exceptions sont également interrompues, avec l'ajout d'une machine à états plus complexe).Deuxièmement,
await
se traduit par une séquence assez simple; J'aime la description de Lucian , qui en mots est à peu près "si l'attendable est déjà terminé, obtenez le résultat et continuez à exécuter cette méthode; sinon, enregistrez l'état de cette méthode et retournez". (J'utilise une terminologie très similaire dans monasync
intro ).Le reste de la méthode existe en tant que callback pour ce qui est attendu (dans le cas des tâches, ces callbacks sont des continuations). Lorsque l'attendable se termine, il appelle ses rappels.
Notez que la pile d'appels n'est pas enregistrée et restaurée; les rappels sont appelés directement. Dans le cas d'E / S superposées, elles sont appelées directement à partir du pool de threads.
Ces rappels peuvent continuer à exécuter la méthode directement, ou ils peuvent la programmer pour qu'elle s'exécute ailleurs (par exemple, si l'
await
interface utilisateur capturéeSynchronizationContext
et les E / S sont terminées sur le pool de threads).Ce ne sont que des rappels. Quand un Waitable se termine, il appelle ses callbacks, et toute
async
méthode qui l'avait déjàawait
édité est repris. Le rappel saute au milieu de cette méthode et a ses variables locales dans la portée.Les rappels n'exécutent pas un thread particulier et leur pile d'appels n'est pas restaurée.
La pile d'appels n'est pas enregistrée en premier lieu; ce n'est pas nécessaire.
Avec le code synchrone, vous pouvez vous retrouver avec une pile d'appels qui inclut tous vos appelants, et le moteur d'exécution sait où revenir en utilisant cela.
Avec du code asynchrone, vous pouvez vous retrouver avec un tas de pointeurs de rappel - enracinés à une opération d'E / S qui termine sa tâche, qui peut reprendre une
async
méthode qui termine sa tâche, qui peut reprendre uneasync
méthode qui termine sa tâche, etc.Ainsi, avec le code synchrone
A
appelantB
appelC
, votre callstack peut ressembler à ceci:alors que le code asynchrone utilise des rappels (pointeurs):
Actuellement, plutôt inefficace. :)
Cela fonctionne comme n'importe quel autre lambda - les durées de vie des variables sont étendues et les références sont placées dans un objet d'état qui vit sur la pile. La meilleure ressource pour tous les détails profonds est la série EduAsync de Jon Skeet .
la source
yield
etawait
sont, tout en traitant du contrôle de flux, deux choses complètement différentes. Je vais donc les aborder séparément.Le but de
yield
est de faciliter la création de séquences paresseuses. Lorsque vous écrivez une boucle d'énumérateur avec uneyield
instruction, le compilateur génère une tonne de nouveau code que vous ne voyez pas. Sous le capot, il génère en fait une toute nouvelle classe. La classe contient des membres qui suivent l'état de la boucle et une implémentation de IEnumerable afin que chaque fois que vous l'appelezMoveNext
, répète cette boucle. Donc, lorsque vous faites une boucle foreach comme celle-ci:le code généré ressemble à quelque chose comme:
À l'intérieur de l'implémentation de mything.items () se trouve un tas de code de machine à états qui fera une "étape" de la boucle puis reviendra. Donc, pendant que vous l'écrivez dans la source comme une simple boucle, sous le capot, ce n'est pas une simple boucle. Donc la supercherie du compilateur. Si vous voulez vous voir, sortez ILDASM ou ILSpy ou des outils similaires et voyez à quoi ressemble l'IL généré. Cela devrait être instructif.
async
etawait
, d'autre part, sont une toute autre bouilloire de poisson. Await est, dans l'abstrait, une primitive de synchronisation. C'est une façon de dire au système "Je ne peux pas continuer tant que cela n'est pas fait". Mais, comme vous l'avez noté, il n'y a pas toujours de fil conducteur.Ce qui est impliqué, c'est ce qu'on appelle un contexte de synchronisation. Il y en a toujours un qui traîne. Le travail de leur contexte de synchronisation est de planifier les tâches qui sont attendues et leurs suites.
Quand vous dites
await thisThing()
, plusieurs choses se produisent. Dans une méthode asynchrone, le compilateur découpe en fait la méthode en morceaux plus petits, chaque morceau étant une section «avant une attente» et une section «après une attente» (ou suite). Lorsque l'attente s'exécute, la tâche en attente et la suite suivante - en d'autres termes, le reste de la fonction - sont passées au contexte de synchronisation. Le contexte s'occupe de la planification de la tâche et, une fois terminé, le contexte exécute la continuation, en transmettant la valeur de retour qu'il souhaite.Le contexte de synchronisation est libre de faire ce qu'il veut tant qu'il planifie des choses. Il pourrait utiliser le pool de threads. Cela pourrait créer un thread par tâche. Il pourrait les exécuter de manière synchrone. Différents environnements (ASP.NET vs WPF) fournissent différentes implémentations de contexte de synchronisation qui font différentes choses en fonction de ce qui est le mieux pour leurs environnements.
(Bonus: vous êtes-vous déjà demandé ce
.ConfigurateAwait(false)
que cela faisait? Il dit au système de ne pas utiliser le contexte de synchronisation actuel (généralement en fonction de votre type de projet - WPF vs ASP.NET par exemple) et d'utiliser à la place celui par défaut, qui utilise le pool de threads).Encore une fois, c'est beaucoup de tromperie de compilateur. Si vous regardez le code généré, c'est compliqué mais vous devriez pouvoir voir ce qu'il fait. Ces types de transformations sont difficiles, mais déterministes et mathématiques, c'est pourquoi c'est génial que le compilateur les fasse pour nous.
PS Il existe une exception à l'existence des contextes de synchronisation par défaut: les applications de console n'ont pas de contexte de synchronisation par défaut. Consultez le blog de Stephen Toub pour plus d'informations. C'est un endroit idéal pour rechercher des informations sur
async
etawait
en général.la source
Normalement, je recommanderais de regarder le CIL, mais dans ce cas, c'est un gâchis.
Ces deux constructions de langage fonctionnent de manière similaire, mais implémentées un peu différemment. Fondamentalement, c'est juste un sucre syntaxique pour une magie de compilateur, il n'y a rien de fou / dangereux au niveau de l'assemblage. Regardons-les brièvement.
yield
est une déclaration plus ancienne et plus simple, et c'est un sucre syntaxique pour une machine à états de base. Une méthode retournantIEnumerable<T>
ouIEnumerator<T>
peut contenir unyield
, qui transforme ensuite la méthode en une fabrique de machines d'état. Une chose que vous devriez remarquer est qu'aucun code de la méthode n'est exécuté au moment où vous l'appelez, s'il y a unyield
intérieur. La raison en est que le code que vous écrivez est transféré vers laIEnumerator<T>.MoveNext
méthode, qui vérifie l'état dans lequel il se trouve et exécute la partie correcte du code.yield return x;
est ensuite converti en quelque chose qui ressemble àthis.Current = x; return true;
Si vous réfléchissez, vous pouvez facilement inspecter la machine d'état construite et ses champs (au moins un pour l'état et pour les locaux). Vous pouvez même le réinitialiser si vous modifiez les champs.
await
nécessite un peu de support de la bibliothèque de types, et fonctionne un peu différemment. Il prend un argumentTask
ouTask<T>
, puis obtient sa valeur si la tâche est terminée, ou enregistre une continuation viaTask.GetAwaiter().OnCompleted
. La mise en œuvre complète du systèmeasync
/await
prendrait trop de temps à expliquer, mais ce n'est pas non plus si mystique. Il crée également une machine à états et la transmet dans la suite à OnCompleted . Si la tâche est terminée, il utilise alors son résultat dans la suite. L'implémentation du Waiter décide comment invoquer la continuation. En général, il utilise le contexte de synchronisation du thread appelant.Les deux
yield
etawait
doivent diviser la méthode en fonction de leur occurrence pour former une machine à états, chaque branche de la machine représentant chaque partie de la méthode.Vous ne devriez pas penser à ces concepts dans les termes de "niveau inférieur" comme les piles, les threads, etc. Ce sont des abstractions, et leur fonctionnement interne ne nécessite aucun support de la part du CLR, c'est juste le compilateur qui fait la magie. C'est très différent des coroutines de Lua, qui ont le support du runtime, ou longjmp de C , qui n'est que de la magie noire.
la source
await
pas besoin de prendre une tâche . Tout avecINotifyCompletion GetAwaiter()
est suffisant. Un peu similaire à commentforeach
n'a pas besoinIEnumerable
, tout avecIEnumerator GetEnumerator()
est suffisant.