web-dev-qa-db-fra.com

Comment le rendement et l'attente pour implémenter un flux de contrôle dans .NET?

Si je comprends bien, le mot clé yield, s'il est utilisé à l'intérieur d'un bloc d'itérateur, renvoie le flux de contrôle au code appelant. Lorsque l'itérateur est appelé à nouveau, il reprend là où il s'était arrêté.

En outre, await n'attend pas seulement l'appelé, il restitue le contrôle à l'appelant, uniquement pour reprendre 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 wait est une illusion provoquée par un flux de contrôle intelligent, dont les détails sont masqués par la syntaxe.

Maintenant, je suis un ancien programmeur d'Assembly et je connais très bien les pointeurs d'instruction, les piles, etc., et je comprends comment fonctionnent les flux de contrôle normaux (sous-programme, récursivité, boucles, branches). Mais ces nouvelles constructions-- Je ne les comprends pas.

Quand un await est atteint, comment le moteur d'exécution sait-il quel morceau de code doit être exécuté ensuite? Comment sait-il quand il peut reprendre où il s'est arrêté, et comment se souvient-il où? Qu'advient-il de la pile d'appels en cours, est-il enregistré d'une manière ou d'une autre? Que se passe-t-il si la méthode appelante effectue d’autres appels avant awaits - pourquoi la pile n’est-elle pas écrasée? Et comment diable le runtime pourrait-il se frayer un chemin à travers tout cela dans le cas d'une exception et d'un dépilage de pile?

Lorsque yield est atteint, comment le moteur d'exécution garde-t-il trace du point où les objets doivent être récupérés? Comment l'état d'itérateur est-il préservé?

102
John Wu

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 obsolètes maintenant; le code généré est différent à bien des égards. Mais cela vous donnera certainement une idée de la façon dont cela fonctionne.

De plus, si vous ne comprenez pas comment les lambdas sont générés en tant que classes de fermeture, comprenez que first. Vous ne ferez pas une tête async si vous n'avez pas de lambda en bas.

Quand 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é en tant que:

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 n'est qu'un retour de fantaisie.

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

Eh bien, comment faites-vous cela sans attendez? Quand method foo appelle barre de méthode, nous nous souvenons en quelque sorte de la façon de revenir au milieu de foo, en conservant intact l'activation de foo, quelle que soit la barre.

Vous savez comment cela se passe dans l'assembleur. Un enregistrement d'activation pour foo est placé dans la pile. il contient les valeurs des locaux. Au moment de l'appel, l'adresse de retour dans foo est ajoutée à la pile. Lorsque bar est terminé, le pointeur de la pile et le pointeur de l'instruction sont réinitialisés à l'endroit où ils doivent être et foo continue d'avancer là où ils l'ont laissé.

La continuation d'une attente est exactement la même chose, 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 numéro qui est l’entrée d’une table de correspondance qui donne le pointeur d’instruction que vous devez exécuter ensuite, et (2) toutes les valeurs des variables locales et temporaires.

Il y a quelques engins supplémentaires à l'intérieur; 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'advient-il de la pile d'appels en cours, est-il enregistré d'une manière ou d'une autre?

Les informations pertinentes dans l'enregistrement d'activation en cours ne sont jamais mises sur la pile. il est alloué dès le départ. (Eh bien, les paramètres formels sont passés normalement 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, souvenez-vous, alors ils seront traités normalement.

Notez qu’il s’agit d’une différence fondamentale entre le style d’attente de continuation de continuation simplifié et les véritables structures d’appel avec continuation de courant que vous voyez dans des langages comme Scheme. Dans ces langues, la continuation complète, y compris la continuation vers les appelants, est capturée par call-cc .

Que se passe-t-il si la méthode appelante effectue d'autres appels avant qu'elle n'attende - pourquoi la pile n'est-elle pas écrasée?

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

Et comment diable le runtime pourrait-il se frayer un chemin à travers tout cela dans le cas d'une exception et d'un dépilage de pile?

Dans le cas d'une exception non interceptée, l'exception est interceptée, stockée à l'intérieur de la tâche et relancée lorsque le résultat de la tâche est extrait.

Vous souvenez-vous de toute la comptabilité que j'ai mentionnée auparavant? Obtenir une sémantique d'exception était une douleur énorme, laissez-moi vous dire.

Lorsque le rendement est atteint, comment le moteur d'exécution garde-t-il trace du point où les objets doivent être récupérés? Comment l'état d'itérateur est-il préservé?

De la même façon. L'état des sections locales est déplacé sur le segment de mémoire et un nombre représentant l'instruction à laquelle MoveNext doit reprendre à la prochaine utilisation est stocké avec les sections locales.

Et encore une fois, il y a un tas d’engins dans un bloc d’itérateurs pour s’assurer que les exceptions sont gérées correctement.

109
Eric Lippert

yield est le plus simple des deux, examinons-le.

Disons que nous avons:

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

Ceci est compilé un bit 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 un __state_, __i_ et __current_ dans ce cas) mais pas mauvais (le truc de se réutiliser quand on peut le faire en toute sécurité plutôt que de créer un nouvel objet est bon), et extensible pour traiter des méthodes très compliquées de yield-.

Et bien sur 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, la MoveNext() générée est appelée à plusieurs reprises.

Le cas async est à peu près le même principe, mais avec un peu plus de complexité. Pour réutiliser un exemple de autre réponse Code comme:

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

Produit un 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 quelconque _awaiter.IsCompleted_ est coché, il retourne true car la tâche awaited est déjà terminée (par exemple, les cas où elle pourrait être renvoyée de manière synchrone), alors la méthode continue de se déplacer dans les états, mais sinon se met en place comme un rappel au serveur.

Ce qui se passe avec cela dépend de l'attente, en termes de ce qui déclenche le rappel (par exemple, achèvement d'E/S asynchrone, exécution d'une tâche sur un thread) et des exigences en matière de marshalling vers un thread particulier ou d'exécution sur un thread de threadpool. , quel contexte de l'appel d'origine peut ou non être nécessaire et ainsi de suite. Quoi qu’il en soit, quelque chose dans cet attente appellera le MoveNext et il continuera soit avec le prochain travail (jusqu’à la prochaine await) ou finira et retournera, auquel cas le Task qu'il implémente devient terminé.

36
Jon Hanna

Il y a déjà une tonne de bonnes réponses ici; Je vais simplement partager quelques points de vue qui peuvent aider à former un modèle mental.

Tout d'abord, une méthode async est décomposée en plusieurs parties par le compilateur; les expressions await sont les points de fracture. (Ceci est facile à concevoir pour des méthodes simples; des méthodes plus complexes avec des boucles et une gestion des exceptions sont également fragmentées, avec l'ajout d'une machine à états plus complexe).

Deuxièmement, await est traduit en une séquence assez simple; J'aime description de Lucian , ce qui, dans les mots, signifie "si l'attente est déjà terminée, obtenez le résultat et continuez à exécuter cette méthode; sinon, enregistrez l'état et le résultat de cette méthode". (J'utilise une terminologie très similaire dans mon async intro ).

Quand 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 rappel pour l'attente (dans le cas de tâches, ces rappels sont des continuations). Lorsque l'attente est terminée, il appelle ses rappels.

Notez que la pile d'appels n'est pas enregistrée et restaurée; les rappels sont invoqués directement. En cas de chevauchement d'E/S, elles sont directement appelées à partir du pool de threads.

Ces callbacks peuvent continuer à exécuter la méthode directement ou ils peuvent planifier son exécution ailleurs (par exemple, si await a capturé une interface utilisateur SynchronizationContext et que les E/S sont terminées sur le pool de threads).

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

Ce ne sont que des rappels. Quand une attente est terminée, elle appelle ses rappels et toute méthode async qui avait déjà été awaited est reprise. Le rappel revient au milieu de cette méthode et a ses variables locales dans la portée.

Les callbacks sont pas exécuter un thread particulier, et ils ne pas ont leur callstack restauré.

Qu'advient-il de la pile d'appels en cours, est-il enregistré d'une manière ou d'une autre? Que se passe-t-il si la méthode appelante effectue d'autres appels avant qu'elle n'attende - pourquoi la pile n'est-elle pas écrasée? Et comment diable le runtime pourrait-il se frayer un chemin à travers tout cela dans le cas d'une exception et d'un dépilage de pile?

Le callstack n'est pas enregistré 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ù retourner en utilisant cela.

Avec du code asynchrone, vous pouvez vous retrouver avec un groupe de pointeurs de rappel - enracinés dans une opération d’E/S qui termine sa tâche, qui peut reprendre une méthode async qui termine sa tâche, ce qui permet de reprendre une tâche async méthode qui termine sa tâche, etc.

Donc, avec le code synchrone A appelant B appelant C, votre pile d'appels peut ressembler à ceci:

A:B:C

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

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

Lorsque le rendement est atteint, comment le moteur d'exécution garde-t-il trace du point où les objets doivent être récupérés? Comment l'état d'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 résidant dans la pile. La meilleure ressource pour tous les détails de niveau profond est série EduAsync de Jon Skeet .

12
Stephen Cleary

yield et await 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 construction de séquences paresseuses. Lorsque vous écrivez une boucle énumérateur avec une instruction yield, le compilateur génère une tonne de nouveau code que vous ne voyez pas. Sous le capot, il génère 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 appelez MoveNext, elle avance de nouveau dans cette boucle. Alors, quand vous faites une boucle foreach comme ceci:

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 (), il y a un tas de code machine à états qui fera une "étape" de la boucle puis retournera. Ainsi, même si vous écrivez dans le code source comme une simple boucle, ce n’est pas une simple boucle. Donc, ruse du compilateur. Si vous voulez vous voir vous-même, sortez ILDASM ou ILSpy ou des outils similaires et voyez à quoi ressemble le IL généré. Cela devrait être instructif.

async et await, par contre, sont une autre marmite de poissons. 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é dans s'appelle un contexte de synchronisation. Il y en a toujours un qui traîne. La tâche de leur contexte de synchronisation consiste à planifier les tâches attendues et leurs suites.

Lorsque vous dites await thisThing(), plusieurs choses se produisent. Dans une méthode asynchrone, le compilateur découpe la méthode en morceaux plus petits, chaque morceau étant une section "avant l'attente" et une section "après l'attente" (ou la suite). Lorsque l'attente est exécutée, la tâche en attente et est la suite suivante - autrement dit, le reste de la fonction - est transmise au contexte de synchronisation. Le contexte prend en charge la planification de la tâche et lorsqu'il est terminé, le contexte exécute ensuite la suite, en transmettant la valeur de retour souhaitée.

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 fil par tâche. Il pourrait les exécuter de manière synchrone. Différents environnements (ASP.NET et WPF) fournissent différentes implémentations de contexte de synchronisation qui font différentes choses en fonction de ce qui convient le mieux à leurs environnements.

(Bonus: vous êtes-vous déjà demandé ce que .ConfigurateAwait(false) fait?)? Il indique au système de ne pas utiliser le contexte de synchronisation actuel (généralement basé sur votre type de projet - WPF vs ASP.NET par exemple) et d'utiliser plutôt le contexte par défaut, qui utilise le pool de threads).

Encore une fois, c'est beaucoup de ruses de compilateur. Si vous regardez le code généré, c'est compliqué, mais vous devriez être capable de voir ce qu'il fait. Ces types de transformations sont difficiles, mais déterministes et mathématiques, c'est pourquoi il est bon que le compilateur les effectue pour nous.

P.S. Il existe une exception à l'existence de contextes de synchronisation par défaut: les applications de la console n'ont pas de contexte de synchronisation par défaut. Vérifiez blog de Stephen Toub pour plus d'informations. C'est un bon endroit pour chercher des informations sur async et await en général.

7
Chris Tavares

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. En gros, c'est juste un sucre syntaxique pour un magicien de compilateur, il n'y a rien de fou/dangereux au niveau de Assembly. Regardons-les brièvement.

yield est une instruction plus ancienne et plus simple, et constitue un sucre syntaxique pour une machine à états de base. Une méthode renvoyant IEnumerable<T> Ou IEnumerator<T> Peut contenir un yield, qui transforme ensuite la méthode en une fabrique de machines à états. Une chose que vous devriez remarquer est qu’aucun code dans la méthode n’est exécuté au moment où vous l’appelez, s’il contient un yield. La raison en est que le code que vous écrivez est transféré vers la méthode IEnumerator<T>.MoveNext, 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 un peu, vous pouvez facilement inspecter la machine à états construite et ses champs (au moins un pour l’état et pour les sections locales). 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 argument Task ou Task<T>, Puis renvoie 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/await prendrait trop de temps à expliquer, mais ce n’est pas aussi mystique. Il crée également une machine à états et la transmet à OnCompleted. Si la tâche est terminée, il utilise ensuite son résultat dans la suite. L'implémentation de l'attendeur décide comment invoquer la suite. Il utilise généralement le contexte de synchronisation du thread appelant.

yield et await doivent tous deux 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 en termes "de niveau inférieur" tels que piles, threads, etc. Ce sont des abstractions, et leur fonctionnement interne ne nécessite aucun support de la part du CLR, c'est simplement le compilateur qui fait la magie. Ceci est très différent des coroutines de Lua, qui ont le support du runtime, ou C's longjmp, qui est juste de la magie noire.

4
IllidanS4