Comment céder et attendre implémenter le flux de contrôle dans .NET?


105

Si je comprends bien le yieldmot - 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, awaitnon 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 awaitsla 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 awaitest 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 yieldest 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é?


4
Vous pouvez jeter un oeil sur le code généré dans le TryRoslyn compilateur en ligne
Xanatos

1
Vous voudrez peut-être consulter la série d'articles Eduasync de Jon Skeet.
Leonid Vasilev

Lecture intéressante connexe: stackoverflow.com/questions/37419572/…
Jason C

Réponses:


115

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.

Lorsqu'une attente est atteinte, comment le moteur d'exécution sait-il quel morceau de code doit être exécuté ensuite?

await est généré comme:

if (the task is not completed)
  assign a delegate which executes the remainder of the method as the continuation of the task
  return to the caller
else
  execute the remainder of the method now

C'est fondamentalement ça. Attendre est juste un retour de fantaisie.

Comment sait-il quand il peut reprendre là où il s'est arrêté et comment se souvient-il où?

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.

Qu'arrive-t-il à la pile d'appels actuelle, est-elle sauvegardée d'une manière ou d'une autre?

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 .

Que faire si la méthode appelante effectue d'autres appels de méthode avant d'attendre, pourquoi la pile n'est-elle pas écrasée?

Ces appels de méthode retournent, et donc leurs enregistrements d'activation ne sont plus sur la pile au moment de l'attente.

Et comment diable le runtime fonctionnerait-il à travers tout cela dans le cas d'une exception et d'une pile se dérouler?

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.

Lorsque le rendement 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é?

De la même façon. L'état des locaux est déplacé sur le tas, et un nombre représentant l'instruction à laquelle MoveNextdoit 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.


1
en raison du contexte des auteurs de questions (assembleur et al), il est peut-être intéressant de mentionner que ces deux constructions ne sont pas possibles sans mémoire gérée. sans mémoire gérée, tenter de coordonner la durée de vie d'une fermeture vous ferait certainement trébucher sur vos bootstraps.
Jim

Le lien de toutes les pages n'est pas trouvé (404)
Digital3D

Tous vos articles ne sont pas disponibles actuellement. Pourriez-vous les republier?
Michał Turczyn

1
@ MichałTurczyn: Ils sont toujours sur Internet; Microsoft continue de se déplacer là où se trouve l'archive du blog. Je les migrerai progressivement vers mon site personnel et j'essaierai de mettre à jour ces liens quand j'aurai le temps.
Eric Lippert

38

yield est le plus facile des deux, alors examinons-le.

Disons que nous avons:

public IEnumerable<int> CountToTen()
{
  for (int i = 1; i <= 10; ++i)
  {
    yield return i;
  }
}

Cela est compilé un peu comme si nous avions écrit:

// Deliberately use name that isn't valid C# to not clash with anything
private class <CountToTen> : IEnumerator<int>, IEnumerable<int>
{
    private int _i;
    private int _current;
    private int _state;
    private int _initialThreadId = CurrentManagedThreadId;

    public IEnumerator<CountToTen> GetEnumerator()
    {
        // Use self if never ran and same thread (so safe)
        // otherwise create a new object.
        if (_state != 0 || _initialThreadId != CurrentManagedThreadId)
        {
            return new <CountToTen>();
        }

        _state = 1;
        return this;
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    public int Current => _current;

    object IEnumerator.Current => Current;

    public bool MoveNext()
    {
        switch(_state)
        {
            case 1:
                _i = 1;
                _current = i;
                _state = 2;
                return true;
            case 2:
                ++_i;
                if (_i <= 10)
                {
                    _current = _i;
                    return true;
                }
                break;
        }
        _state = -1;
        return false;
    }

    public void Dispose()
    {
      // if the yield-using method had a `using` it would
      // be translated into something happening here.
    }

    public void Reset()
    {
        throw new NotSupportedException();
    }
}

Donc, pas aussi efficace qu'une implémentation manuscrite de IEnumerable<int>et IEnumerator<int>(par exemple, nous ne gaspillerions probablement pas d'avoir un séparé _state, _iet _currentdans 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 des yieldméthodes très compliquées .

Et bien sûr depuis

foreach(var a in b)
{
  DoSomething(a);
}

Est le même que:

using(var en = b.GetEnumerator())
{
  while(en.MoveNext())
  {
     var a = en.Current;
     DoSomething(a);
  }
}

Ensuite, le généré MoveNext()est appelé à plusieurs reprises.

Le asynccas 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:

private async Task LoopAsync()
{
    int count = 0;
    while(count < 5)
    {
       await SomeNetworkCallAsync();
       count++;
    }
}

Produit du code comme:

private struct LoopAsyncStateMachine : IAsyncStateMachine
{
  public int _state;
  public AsyncTaskMethodBuilder _builder;
  public TestAsync _this;
  public int _count;
  private TaskAwaiter _awaiter;
  void IAsyncStateMachine.MoveNext()
  {
    try
    {
      if (_state != 0)
      {
        _count = 0;
        goto afterSetup;
      }
      TaskAwaiter awaiter = _awaiter;
      _awaiter = default(TaskAwaiter);
      _state = -1;
    loopBack:
      awaiter.GetResult();
      awaiter = default(TaskAwaiter);
      _count++;
    afterSetup:
      if (_count < 5)
      {
        awaiter = _this.SomeNetworkCallAsync().GetAwaiter();
        if (!awaiter.IsCompleted)
        {
          _state = 0;
          _awaiter = awaiter;
          _builder.AwaitUnsafeOnCompleted<TaskAwaiter, TestAsync.LoopAsyncStateMachine>(ref awaiter, ref this);
          return;
        }
        goto loopBack;
      }
      _state = -2;
      _builder.SetResult();
    }
    catch (Exception exception)
    {
      _state = -2;
      _builder.SetException(exception);
      return;
    }
  }
  [DebuggerHidden]
  void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine param0)
  {
    _builder.SetStateMachine(param0);
  }
}

public Task LoopAsync()
{
  LoopAsyncStateMachine stateMachine = new LoopAsyncStateMachine();
  stateMachine._this = this;
  AsyncTaskMethodBuilder builder = AsyncTaskMethodBuilder.Create();
  stateMachine._builder = builder;
  stateMachine._state = -1;
  builder.Start(ref stateMachine);
  return builder.Task;
}

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 moment awaiter.IsCompletedest coché, il retourne trueparce que la tâche awaited 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 MoveNextet il continuera soit avec le travail suivant (jusqu'au suivant await), soit se terminera et retournera, auquel cas Taskcelui qu'il implémentera sera terminé.


Avez-vous pris le temps de rouler votre propre traduction? O_O uao.
CoffeDeveloper

4
@DarioOO Le premier que je peux faire assez rapidement, ayant fait beaucoup de traductions de 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.
Jon Hanna

13

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 asyncméthode est divisée en plusieurs morceaux par le compilateur; les awaitexpressions 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, awaitse 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 mon asyncintro ).

Lorsqu'une attente est atteinte, comment le moteur d'exécution sait-il quel morceau de code doit être exécuté ensuite?

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' awaitinterface utilisateur capturée SynchronizationContextet les E / S sont terminées sur le pool de threads).

Comment sait-il quand il peut reprendre là où il s'est arrêté et comment se souvient-il où?

Ce ne sont que des rappels. Quand un Waitable se termine, il appelle ses callbacks, et toute asyncmé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.

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 d'attendre, 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?

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 asyncméthode qui termine sa tâche, qui peut reprendre une asyncméthode qui termine sa tâche, etc.

Ainsi, avec le code synchrone Aappelant Bappel C, votre callstack peut ressembler à ceci:

A:B:C

alors que le code asynchrone utilise des rappels (pointeurs):

A <- B <- C <- (I/O operation)

Lorsque le rendement 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é?

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 .


7

yieldet awaitsont, 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 yieldest de faciliter la création de séquences paresseuses. Lorsque vous écrivez une boucle d'énumérateur avec une yieldinstruction, 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'appelez MoveNext, répète cette boucle. Donc, lorsque vous faites une boucle foreach comme celle-ci:

foreach(var item in mything.items()) {
    dosomething(item);
}

le code généré ressemble à quelque chose comme:

var i = mything.items();
while(i.MoveNext()) {
    dosomething(i.Current);
}

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

asyncet await, 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 asyncet awaiten général.


1
"Il dit au système de ne pas utiliser le contexte de synchronisation par défaut et d'utiliser à la place celui par défaut, qui utilise le pool de threads" Pouvez-vous clarifier ce que vous voulez dire avec cela? "ne pas utiliser la valeur par défaut, utiliser la valeur par défaut"
Kroltan

3
Désolé, j'ai mélangé ma terminologie, je vais corriger le message. Fondamentalement, n'utilisez pas celui par défaut pour l'environnement dans lequel vous vous trouvez, utilisez celui par défaut pour .NET (c'est-à-dire le pool de threads).
Chris Tavares

très simple, a pu comprendre, vous avez eu mon vote :)
Ehsan Sajjad

4

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.

yieldest une déclaration plus ancienne et plus simple, et c'est un sucre syntaxique pour une machine à états de base. Une méthode retournant IEnumerable<T>ou IEnumerator<T>peut contenir un yield, 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 un yieldintérieur. La raison en est que le code que vous écrivez est transféré vers la IEnumerator<T>.MoveNextmé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.

awaitnécessite un peu de support de la bibliothèque de types, et fonctionne un peu différemment. Il prend un argument Taskou Task<T>, puis obtient sa valeur si la tâche est terminée, ou enregistre une continuation via Task.GetAwaiter().OnCompleted. La mise en œuvre complète du système async/ awaitprendrait 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 yieldet awaitdoivent 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.


5
Note latérale : awaitpas besoin de prendre une tâche . Tout avec INotifyCompletion GetAwaiter()est suffisant. Un peu similaire à comment foreachn'a pas besoin IEnumerable, tout avec IEnumerator GetEnumerator()est suffisant.
IllidanS4 veut que Monica revienne le
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.