web-dev-qa-db-fra.com

Comment StartCoroutine / modèle de retour de rendement fonctionne-t-il vraiment dans Unity?

Je comprends le principe des coroutines. Je sais comment obtenir le modèle standard StartCoroutine/yield return Pour qu'il fonctionne en C # dans Unity, par exemple. invoquez une méthode renvoyant IEnumerator via StartCoroutine et dans laquelle cette méthode fait quelque chose, faites yield return new WaitForSeconds(1); pour attendre une seconde, puis faites autre chose.

Ma question est la suivante: que se passe-t-il réellement dans les coulisses? Que fait vraiment StartCoroutine? Qu'est-ce que IEnumerator est-ce que WaitForSeconds renvoie? Comment StartCoroutine renvoie-t-il le contrôle à la partie "autre chose" de la méthode appelée? Comment tout cela interagit-il avec le modèle de concurrence de Unity (où beaucoup de choses se passent en même temps sans utiliser de coroutines)?

117
Ghopper21

Le lien souvent référencé les coroutines Unity3D en détail le lien est mort. Comme il est mentionné dans les commentaires et les réponses, je vais poster le contenu de l'article ici. Ce contenu provient de ce miroir .


Coroutines Unity3D en détail

De nombreux processus dans les jeux se déroulent au cours de plusieurs images. Vous avez des processus "denses", comme pathfinding, qui travaillent avec acharnement à chaque image mais sont scindés en plusieurs images pour ne pas trop impacter la vitesse de traitement. Vous avez des processus "clairsemés", tels que des déclencheurs de jeu, qui ne font pas la plupart des images, mais qui sont parfois appelés à effectuer un travail critique. Et vous avez des processus assortis entre les deux.

Chaque fois que vous créez un processus qui se déroulera sur plusieurs images - sans multithreading - vous devez trouver un moyen de fractionner le travail en plusieurs parties pouvant être exécutées une par image. Pour tout algorithme avec une boucle centrale, c'est assez évident: un A * pathfinder, par exemple, peut être structuré de manière à maintenir ses listes de noeuds de manière semi-permanente, ne traitant que quelques noeuds de la liste ouverte à chaque frame, au lieu d'essayer faire tout le travail en une fois. Il y a un certain équilibre à faire pour gérer la latence - après tout, si vous verrouillez votre fréquence d'image à 60 ou 30 images par seconde, votre processus ne prendra que 60 ou 30 étapes par seconde, ce qui pourrait entraîner le processus. trop long dans l'ensemble. Une conception soignée pourrait offrir la plus petite unité de travail possible à un niveau - par ex. traiter un seul nœud A * - et superposer au-dessus un moyen de regrouper les tâches en de plus grands morceaux - par ex. continue le traitement des noeuds A * pendant X millisecondes. (Certaines personnes appellent cela le "timelicing", bien que ce ne soit pas le cas).

Néanmoins, autoriser le travail à être divisé de cette manière signifie que vous devez transférer l’état d’une image à l’autre. Si vous cassez un algorithme itératif, vous devez conserver tout l’état partagé entre les itérations, ainsi qu’un moyen de suivre l’itération à effectuer par la suite. C’est pas mal en général - la conception d’une classe de "classe A" est assez évidente - mais il existe aussi d’autres cas moins agréables. Parfois, vous devrez faire face à de longs calculs qui effectuent différents types de travail d’une image à l’autre; l’objet capturant leur état peut se retrouver avec un grand fouillis de "locaux" semi-utiles, conservés pour la transmission de données d’une trame à la suivante. Et si vous traitez avec un processus épars, vous finissez souvent par devoir implémenter une petite machine à états juste pour suivre quand le travail doit être fait.

Ne serait-il pas judicieux que, au lieu d'avoir à suivre explicitement tout cet état sur plusieurs cadres, et au lieu d'avoir à multithread et à gérer la synchronisation et le verrouillage, etc., vous puissiez simplement écrire votre fonction sous la forme d'un seul bloc de code, et marquer des endroits particuliers où la fonction doit "faire une pause" et continuer plus tard?

L'unité - avec un certain nombre d'autres environnements et langages - fournit ceci sous la forme de Coroutines.

A quoi ressemblent-ils? En “Unityscript” (Javascript):

function LongComputation()
{
    while(someCondition)
    {
        /* Do a chunk of work */

        // Pause here and carry on next frame
        yield;
    }
}

En C #:

IEnumerator LongComputation()
{
    while(someCondition)
    {
        /* Do a chunk of work */

        // Pause here and carry on next frame
        yield return null;
    }
}

Comment travaillent-ils? Permettez-moi de dire rapidement que je ne travaille pas pour Unity Technologies. Je n'ai pas vu le code source de Unity. Je n’ai jamais vu les entrailles du moteur Coroutine de Unity. Cependant, s’ils l’ont mis en œuvre d’une manière radicalement différente de celle que je vais décrire, je serai très surpris. Si quelqu'un de UT veut intervenir et parler de son fonctionnement, alors ce serait formidable.

Les grands indices sont dans la version C #. Tout d'abord, notez que le type de retour pour la fonction est IEnumerator. Et deuxièmement, notez que l’un des énoncés est rendement. Cela signifie que le rendement doit être un mot clé et que le support C # de Unity est défini sur Vanilla C # 3.5, il doit s'agir d'un mot clé Vanilla C # 3.5. En effet, ici dans MSDN - parle de quelque chose appelée "blocs d’itérateur". Que se passe-t-il alors?

Premièrement, il y a ce type IEnumerator. Le type IEnumerator agit comme un curseur sur une séquence, fournissant deux membres significatifs: Current, qui est une propriété donnant l'élément sur lequel se trouve actuellement le curseur, et MoveNext (), une fonction qui permet de passer à l'élément suivant de la séquence. IEnumerator étant une interface, il ne précise pas comment ces membres sont implémentés. MoveNext () pourrait simplement ajouter un élément àCurrent ou charger la nouvelle valeur à partir d'un fichier. Il pourrait aussi télécharger une image à partir d'Internet et la hacher et stocker le nouveau hachage dans Current… ou bien même faire une chose pour la première. élément dans la séquence, et quelque chose de complètement différent pour la seconde. Vous pouvez même l'utiliser pour générer une séquence infinie si vous le souhaitez. MoveNext () calcule la valeur suivante de la séquence (renvoie false s'il n'y a plus de valeurs) et Current récupère la valeur calculée.

Normalement, si vous souhaitez implémenter une interface, vous devez écrire une classe, implémenter les membres, etc. Les blocs Iterator sont un moyen pratique d'implémenter IEnumerator sans tout ce dont vous avez besoin - vous n'avez qu'à suivre quelques règles et l'implémentation d'IEnumerator est générée automatiquement par le compilateur.

Un bloc itérateur est une fonction régulière qui (a) renvoie IEnumerator et (b) utilise le mot-clé de rendement. Alors, que fait réellement le mot-clé rendement? Il déclare quelle est la prochaine valeur de la séquence - ou qu'il n'y a plus de valeurs. Le point auquel le code rencontre un retour de rendement X ou une rupture de rendement est le point auquel IEnumerator.MoveNext () doit s'arrêter; un retour de rendement X force MoveNext () à retourner true et que Current se voit attribuer la valeur X, alors qu'une interruption de rendement entraîne MoveNext () à retourner False.

Maintenant, voici le truc. Peu importe les valeurs réelles renvoyées par la séquence. Vous pouvez appeler MoveNext () à plusieurs reprises et ignorer Current; les calculs seront toujours effectués. Chaque fois que MoveNext () est appelé, votre bloc itérateur s'exécute vers la prochaine instruction ″ rendement ’, quelle que soit l'expression qu'il génère. Donc, vous pouvez écrire quelque chose comme:

IEnumerator TellMeASecret()
{
  PlayAnimation("LeanInConspiratorially");
  while(playingAnimation)
    yield return null;

  Say("I stole the cookie from the cookie jar!");
  while(speaking)
    yield return null;

  PlayAnimation("LeanOutRelieved");
  while(playingAnimation)
    yield return null;
}

et ce que vous avez réellement écrit est un bloc itérateur qui génère une longue séquence de valeurs nulles, mais ce qui est important, ce sont les effets secondaires du travail effectué pour les calculer. Vous pouvez exécuter cette coroutine en utilisant une simple boucle comme celle-ci:

IEnumerator e = TellMeASecret();
while(e.MoveNext()) { }

Ou, plus utilement, vous pouvez le mélanger avec d'autres travaux:

IEnumerator e = TellMeASecret();
while(e.MoveNext()) 
{ 
  // If they press 'Escape', skip the cutscene
  if(Input.GetKeyDown(KeyCode.Escape)) { break; }
}

Comme vous l’avez vu, chaque instruction return return doit fournir une expression (telle que null) afin que le bloc itérateur ait quelque chose à affecter à IEnumerator.Current. Une longue séquence de valeurs nuls n’est pas vraiment utile, mais nous nous intéressons davantage aux effets secondaires. N'est-ce pas?

En fait, nous pouvons faire quelque chose de pratique avec cette expression. Et si, au lieu de donner null et de l'ignorer, nous donnions quelque chose qui indiquait quand nous nous attendions à devoir faire plus de travail? Bien souvent, nous aurons besoin de continuer tout droit, mais pas toujours: nous voudrons bien continuer après une animation ou un son, ou après un laps de temps particulier. Ceux en jouant (playAnimation) donnent un retour nul; les constructions sont un peu fastidieuses, vous ne pensez pas?

Unity déclare le type de base YieldInstruction et fournit quelques types dérivés concrets qui indiquent des types d’attente particuliers. Vous avez WaitForSeconds, qui reprend la coroutine une fois le délai imparti écoulé. Vous avez WaitForEndOfFrame, qui reprend la coroutine à un moment donné plus tard dans la même image. Vous avez le type Coroutine lui-même qui, lorsque la coroutine A cède la coroutine B, met la coroutine en pause jusqu'à ce que la coroutine B soit terminée.

À quoi cela ressemble-t-il du point de vue de l'exécution? Comme je l’ai dit, je ne travaille pas pour Unity, je n’ai donc jamais vu leur code; mais j’imagine que cela pourrait ressembler un peu à ceci:

List<IEnumerator> unblockedCoroutines;
List<IEnumerator> shouldRunNextFrame;
List<IEnumerator> shouldRunAtEndOfFrame;
SortedList<float, IEnumerator> shouldRunAfterTimes;

foreach(IEnumerator coroutine in unblockedCoroutines)
{
    if(!coroutine.MoveNext())
        // This coroutine has finished
        continue;

    if(!coroutine.Current is YieldInstruction)
    {
        // This coroutine yielded null, or some other value we don't understand; run it next frame.
        shouldRunNextFrame.Add(coroutine);
        continue;
    }

    if(coroutine.Current is WaitForSeconds)
    {
        WaitForSeconds wait = (WaitForSeconds)coroutine.Current;
        shouldRunAfterTimes.Add(Time.time + wait.duration, coroutine);
    }
    else if(coroutine.Current is WaitForEndOfFrame)
    {
        shouldRunAtEndOfFrame.Add(coroutine);
    }
    else /* similar stuff for other YieldInstruction subtypes */
}

unblockedCoroutines = shouldRunNextFrame;

Il n’est pas difficile d’imaginer comment d’autres sous-types YieldInstruction pourraient être ajoutés pour traiter d’autres cas - une prise en charge des signaux au niveau du moteur, par exemple, pourrait être ajoutée, avec une instruction WaitForSignal ("SignalName") la prenant en charge. En ajoutant plus d'instructions Yield, les coroutines elles-mêmes peuvent devenir plus expressives - rendement, nouveau retour WaitForSignal ("GameOver") est plus agréable à lire que! le fait que le faire dans le moteur pourrait être plus rapide que de le faire dans un script.

Quelques ramifications non évidentes Il y a quelques aspects utiles de tout cela qui manquent parfois aux gens et que je pensais devoir signaler.

Premièrement, le retour de rendement ne fait que donner une expression - toute expression - et YieldInstruction est un type régulier. Cela signifie que vous pouvez faire des choses comme:

YieldInstruction y;

if(something)
 y = null;
else if(somethingElse)
 y = new WaitForEndOfFrame();
else
 y = new WaitForSeconds(1.0f);

yield return y;

Les lignes spécifiques return return new WaitForSeconds (), return return new WaitForEndOfFrame (), etc., sont courantes, mais ce ne sont pas des formes spéciales à part entière.

Deuxièmement, comme ces coroutines ne sont que des blocs itérateurs, vous pouvez les parcourir vous-même si vous le souhaitez - vous n’avez pas à laisser le moteur le faire pour vous. J'ai déjà utilisé cela pour ajouter des conditions d'interruption à une coroutine avant:

IEnumerator DoSomething()
{
  /* ... */
}

IEnumerator DoSomethingUnlessInterrupted()
{
  IEnumerator e = DoSomething();
  bool interrupted = false;
  while(!interrupted)
  {
    e.MoveNext();
    yield return e.Current;
    interrupted = HasBeenInterrupted();
  }
}

Troisièmement, le fait que vous puissiez céder sur d’autres coroutines peut vous permettre en quelque sorte de mettre en œuvre vos propres instructions de rendement, bien que cela ne soit pas aussi performant que si elles étaient implémentées par le moteur. Par exemple:

IEnumerator UntilTrueCoroutine(Func fn)
{
   while(!fn()) yield return null;
}

Coroutine UntilTrue(Func fn)
{
  return StartCoroutine(UntilTrueCoroutine(fn));
}

IEnumerator SomeTask()
{
  /* ... */
  yield return UntilTrue(() => _lives < 3);
  /* ... */
}

cependant, je ne le recommanderais pas vraiment - le coût de démarrage d’une Coroutine est un peu lourd à mon goût.

Conclusion J’espère que cela clarifie un peu ce qui se passe réellement lorsque vous utilisez une Coroutine dans Unity. Les blocs itérateurs de C # sont une petite construction géniale, et même si vous n’utilisez pas Unity, vous trouverez peut-être utile de les exploiter de la même manière.

94
James McMahon

La première rubrique ci-dessous est une réponse directe à la question. Les deux titres suivants sont plus utiles pour le programmeur quotidien.

Peut-être ennuyeux Détails d'implémentation de Coroutines

Les coroutines sont expliquées dans Wikipedia et ailleurs. Ici, je vais juste fournir quelques détails d'un point de vue pratique. IEnumerator, yield, etc. sont fonctionnalités du langage C # qui sont utilisés à des fins quelque peu différentes dans Unity.

Pour le dire très simplement, un IEnumerator prétend avoir une collection de valeurs que vous pouvez demander une à une, un peu comme un List. En C #, une fonction avec une signature pour renvoyer un IEnumerator ne doit pas en créer ni en renvoyer, mais peut permettre à C # de fournir un IEnumerator implicite. La fonction peut alors fournir le contenu de celui retourné IEnumerator à l'avenir de manière paresseuse, via les instructions yield return. Chaque fois que l'appelant demande une autre valeur de cette implicite IEnumerator, la fonction est exécutée jusqu'à la prochaine instruction yield return, Qui fournit la valeur suivante. En guise de sous-produit, la fonction s'interrompt jusqu'à ce que la valeur suivante soit demandée.

Dans Unity, nous ne les utilisons pas pour fournir des valeurs futures, nous exploitons le fait que la fonction se met en pause. En raison de cette exploitation, beaucoup de choses sur les coroutines dans Unity n'ont pas de sens (Qu'est-ce que IEnumerator a à voir avec quoi que ce soit? Qu'est-ce que yield? Pourquoi new WaitForSeconds(3)? etc.). Ce qui se passe "sous le capot", c’est que les valeurs que vous fournissez via IEnumerator sont utilisées par StartCoroutine() pour décider quand demander la valeur suivante, ce qui détermine le moment où votre coroutine sera à nouveau en pause.

Votre jeu Unity est à fil unique (*)

Les coroutines ne sont pas . Il existe une boucle principale de Unity et toutes les fonctions que vous écrivez sont appelées par le même fil principal dans l’ordre. Vous pouvez le vérifier en plaçant une while(true); dans l'une de vos fonctions ou coroutines. Cela va geler le tout, même l'éditeur Unity. Ceci est la preuve que tout fonctionne dans un seul fil. Ce lien que Kay a mentionné dans son commentaire ci-dessus est également une excellente ressource.

(*) Unity appelle vos fonctions depuis un seul thread. Ainsi, à moins que vous ne créiez vous-même un thread, le code que vous avez écrit est à thread unique. Bien sûr, Unity utilise d'autres threads et vous pouvez en créer vous-même si vous le souhaitez.

Une description pratique de Coroutines pour les programmeurs de jeux

En gros, lorsque vous appelez StartCoroutine(MyCoroutine()), cela ressemble exactement à un appel de fonction normal à MyCoroutine(), jusqu'au premier yield return X, Où X est quelque chose comme null, new WaitForSeconds(3), StartCoroutine(AnotherCoroutine()), break, etc. C'est à ce moment qu'il commence à différer d'une fonction. Unity "met en pause" cette fonction juste à la ligne yield return X, Continue avec d'autres affaires et certaines images passent, et quand l'heure est venue, Unity reprend cette fonction juste après cette ligne. Il mémorise les valeurs de toutes les variables locales de la fonction. De cette façon, vous pouvez avoir une boucle for qui boucle toutes les deux secondes, par exemple.

Quand Unity reprendra votre tâche, cela dépend de ce que X était dans votre yield return X. Par exemple, si vous avez utilisé yield return new WaitForSeconds(3);, il reprend après 3 secondes. Si vous avez utilisé yield return StartCoroutine(AnotherCoroutine()), il reprend après que AnotherCoroutine() soit complètement terminé, ce qui vous permet d'imbriquer des comportements dans le temps. Si vous venez d'utiliser un yield return null;, Il reprend à la prochaine image.

93
Gazihan Alankus

Ça ne pourrait pas être plus simple:

L'unité (et tous les moteurs de jeu) sont basés sur des images .

Tout l’essentiel, toute la raison d’être de l’Unité, c’est qu’il est basé sur un cadre. Le moteur fait "chaque image" pour vous. (Anime, restitue des objets, fait de la physique, etc.)

Vous pourriez demander… "Oh, c'est génial. Et si je veux que le moteur fasse quelque chose pour moi à chaque image? Comment puis-je dire au moteur de faire telle ou telle chose dans un cadre?"

La réponse est ...

C'est exactement à quoi sert une "coroutine".

C'est aussi simple que cela.

Et considérez ceci ....

Vous connaissez la fonction "Mise à jour". Tout simplement, tout ce que vous y mettez est fait à chaque image . C'est littéralement exactement la même chose, pas de différence du tout, avec la syntaxe de la coroutine-rendement.

void Update()
 {
 this happens every frame,
 you want Unity to do something of "yours" in each of the frame,
 put it in here
 }

...in a coroutine...
 while(true)
 {
 this happens every frame.
 you want Unity to do something of "yours" in each of the frame,
 put it in here
 yield return null;
 }

Il n'y a absolument aucune différence.

Note de bas de page: comme tout le monde l’a fait remarquer, Unity simplement n’a pas de fils . Les "frames" dans Unity ou dans n’importe quel moteur de jeu n’ont absolument aucune connexion avec les threads.

Les Coroutines/Yield sont simplement la façon dont vous accédez aux cadres dans Unity. C'est ça. (Et en effet, c'est absolument la même chose que la fonction Update () fournie par Unity.) C'est tout ce que vous avez à faire, c'est aussi simple que cela.

10
Fattie

Avez-vous récemment creusé dans cette affaire, a écrit un article ici - http://eppz.eu/blog/understanding-ienumerator-in-unity-3d/ - qui jette une lumière sur les éléments internes ( avec des exemples de code dense), l’interface sous-jacente IEnumerator et son utilisation pour les coroutines.

Utiliser des énumérateurs de collection à cette fin me semble toujours un peu bizarre. C'est l'inverse de ce pour quoi les enquêteurs se sentent conçus. Le point des énumérateurs est la valeur renvoyée pour chaque accès, mais le point des Coroutines est le code entre les valeurs renvoyées. La valeur renvoyée réelle est inutile dans ce contexte.

6
Geri