Как шаблон возврата StartCoroutine / yield действительно работает в Unity?

Я понимаю принцип coroutines. Я знаю, как получить стандарт StartCoroutine / yield return pattern для работы в C# в Unity, например, вызовите метод returning IEnumerator via StartCoroutine и в этом методе что-то делать, делать yield return new WaitForSeconds(1); подождать секунду, а затем сделать что-то еще.

мой вопрос: что на самом деле происходит за кулисами? Что делает StartCoroutine действительно? Что?!--2--> и WaitForSeconds возвращение? Как это StartCoroutine вернуть управление в часть" что-то еще " вызываемого метод? Как все это взаимодействовать с параллелизмом модель единства (где много чего происходит одновременно без использования сопрограмм)?

4 ответов


часто ссылается Unity3D coroutines подробно ссылка мертвая. Поскольку это упоминается в комментариях и ответах, я собираюсь опубликовать содержание статьи здесь. Это содержание происходит от это зеркало.


Unity3D coroutines подробно

многие процессы в игре происходят в течение нескольких кадров. У вас есть "плотные" процессы, такие как pathfinding, которые усердно работают каждый кадр, но получают разделить на несколько кадров, чтобы не слишком сильно влиять на частоту кадров. У вас есть "разреженные" процессы, такие как триггеры геймплея, которые ничего не делают большинство кадров, но иногда призваны выполнять критическую работу. И у вас есть разные процессы между ними.

всякий раз, когда вы создаете процесс, который будет проходить через несколько кадров – без многопоточности – вам нужно найти способ разбить работу на куски, которые могут быть запущены по одному кадру. Для любого алгоритм с центральным циклом, это довольно очевидно: a * pathfinder, например, может быть структурирован таким образом, что он поддерживает свои списки узлов полупостоянно, обрабатывая только несколько узлов из открытого списка каждый кадр, вместо того, чтобы пытаться сделать всю работу за один раз. В конце концов, если вы блокируете частоту кадров на 60 или 30 кадров в секунду, то ваш процесс будет выполнять только 60 или 30 шагов в секунду, и это может привести к тому, что процесс просто слишком долго. Аккуратный дизайн может предложить наименьшую возможную единицу работы на одном уровне-например, обработать один узел A* – и слой сверху способ группировки работы вместе в большие куски-например, продолжать обработку узлов A* в течение X миллисекунд. (Некоторые люди называют это "timeslicing", хотя я этого не делаю).

тем не менее, позволяя работе быть разбитой таким образом, означает, что вы должны передать состояние от одного кадра к другому. Если вы нарушаете итерационный алгоритм, то вы должны сохранить все состояние, общее для итераций, а также средство отслеживания, какая итерация должна быть выполнена следующей. Обычно это не так уж плохо – дизайн класса "A * pathfinder" довольно очевиден – - но есть и другие случаи, которые менее приятны. Иногда вы столкнетесь с длинными вычислениями, которые выполняют разные виды работы от кадра к кадру; объект, захватывающий их состояние, может закончиться большим беспорядком полуполезных "местных жителей", сохраняемых для передачи данных из один кадр за другим. И если вы имеете дело с редким процессом, вам часто приходится внедрять небольшую государственную машину, чтобы отслеживать, когда работа должна быть выполнена вообще.

не было бы аккуратно, если бы вместо того, чтобы явно отслеживать все это состояние на нескольких кадрах, и вместо того, чтобы многопоточно и управлять синхронизацией и блокировкой и так далее, вы могли бы просто написать свою функцию как один кусок кода и отметить определенные места, где функция должна "пауза" и продолжить позже?

единство – вместе с рядом других средах и языках – предоставляет такую возможность в виде сопрограмм.

как они выглядят? В "Unityscript" (Javascript):

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

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

В C#:

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

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

как они работают? Позвольте мне сразу сказать, что я не работаю на Unity Technologies. Я не видел исходный код Unity. Я никогда не видел мужества. двигателя сопрограмма единства. Однако, если они реализовали его способом, который радикально отличается от того, что я собираюсь описать, тогда я буду очень удивлен. Если кто-нибудь из UT хочет вмешаться и поговорить о том, как это на самом деле работает, тогда это было бы здорово.

Большие Ключи в версии C#. Во-первых, обратите внимание, что типом возврата для функции является IEnumerator. И во-вторых, обратите внимание, что одно из заявлений-yield возвращаться. Это означает, что yield должен быть ключевым словом, и поскольку поддержка Unity C# - это vanilla C# 3.5, это должно быть ключевое слово vanilla C# 3.5. Действительно,вот он в MSDN – говорим о том, что называется ‘итератор блоков.- Так что же происходит?

во-первых, есть этот тип IEnumerator. Тип IEnumerator действует как курсор над последовательностью, предоставляя два значимых элемента: Current, который является свойством, дающим вам элемент, над которым курсор в настоящее время находится, и MoveNext (), функция, которая перемещается к следующему элементу в последовательность. Поскольку IEnumerator является интерфейсом, он не указывает точно, как эти члены реализованы; MoveNext() может просто добавить один ток, или он может загрузить новое значение из файла, или он может загрузить изображение из интернета и хэшировать его и сохранить новый хэш в текущем... или он может даже сделать одну вещь для первого элемента в последовательности, и что-то совершенно другое для второго. Вы даже можете использовать его для генерации бесконечной последовательности, если захотите. метод MoveNext() вычисляет следующее значение в последовательности (возвращает false, если больше нет значений), а Current получает вычисленное значение.

обычно, если вы хотите реализовать интерфейс, вам нужно написать класс, реализовать членов и так далее. Блоки итераторов-удобный способ реализации IEnumerator без всех этих хлопот – вы просто следуете нескольким правилам, и реализация IEnumerator генерируется автоматически компилятором.

An блок итератора-это регулярная функция, которая (a) возвращает IEnumerator, и (b) использует ключевое слово yield. Итак, что же на самом деле делает ключевое слово yield? Он заявляет, что следующее значение в последовательности или нет больше значений. Точка, в которой код сталкивается с выходом return X или yield break-это точка, в которой IEnumerator.MoveNext () должен остановиться; возврат доходности X заставляет MoveNext () возвращать true andCurrent для присвоения значения X, в то время как выход перерыв вызывает MoveNext() для возвращать false.

теперь, вот трюк. Это не должно иметь значения, каковы фактические значения, возвращаемые последовательностью. Вы можете вызвать MoveNext() repeatly и игнорировать Current; вычисления все равно будут выполняться. Каждый раз, когда вызывается MoveNext (), ваш блок итератора переходит к следующему оператору "yield", независимо от того, какое выражение он фактически дает. Таким образом, вы можете написать что-то вроде:

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;
}

и то, что вы на самом деле написали, это итератор блок, который генерирует длинную последовательность нулевых значений, но что важно, так это побочные эффекты работы, которую он выполняет для их вычисления. Вы можете запустить эту корутину, используя простой цикл:

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

или, что более полезно, вы можете смешать его с другой работой:

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

все дело во времени Как вы видели, каждый оператор yield return должен предоставлять выражение (например, null), чтобы блок итератора имел что-то на самом деле назначить IEnumerator.Текущий. Длинная последовательность нулей не совсем полезна, но нас больше интересуют побочные эффекты. Не так ли?

есть что-то удобное, что мы можем сделать с этим выражением, на самом деле. Что делать, если вместо того, чтобы просто давать null и, игнорируя это, мы дали что-то, что указывало, когда мы ожидаем, что нам нужно сделать больше работы? Часто нам нужно будет нести прямо на следующий кадр, конечно, но не всегда: будет много раз, когда мы хотите продолжить после того, как анимация или звук закончили играть, или после определенного количества времени прошло. Те, пока (playingAnimation) yield return null; конструкции немного утомительны, не так ли?

Unity объявляет базовый тип YieldInstruction и предоставляет несколько конкретных производных типов, которые указывают на определенные виды ожидания. У вас есть WaitForSeconds, который возобновляет корутин после того, как определенное количество времени прошло. У вас есть WaitForEndOfFrame, который возобновляет корутину в определенной точке позже в том же фрейме. У вас есть сам тип корутина, который, когда корутина A дает корутину B, приостанавливает корутину A до тех пор, пока корутина B не закончит.

как это выглядит с точки зрения среды выполнения? Как я уже сказал, Я не работаю в Unity, поэтому я никогда не видел их код; но я бы предположил, что это может выглядеть немного так:

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;

нетрудно представить, как можно добавить подтипы YieldInstruction для обработки других случаев – например, можно добавить поддержку сигналов на уровне ядра с поддержкой WaitForSignal("SignalName")YieldInstruction. Путем добавлять больше YieldInstructions, coroutines сами могут стать выразительн-выходом возврат новый WaitForSignal("сайт Gameover") приятнее читать thanwhile(!Сигналы.HasFired("Сайт Gameover")) yield return null, если вы спросите меня, не говоря уже о том, что делать это в двигателе можно быстрее чем делать это по сценарию.

несколько неочевидных последствий Во всем этом есть пара полезных вещей, которые люди иногда упускают, и я подумал, что должен указать на них.

во – первых, доходность просто дает выражение – любое выражение-и YieldInstruction является регулярным типом. Это означает, что вы можете делать вещи, как:

YieldInstruction y;

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

yield return y;

конкретные строки yield возвращают новые WaitForSeconds (), yield return new WaitForEndOfFrame () и т. д. являются общими, но на самом деле они не являются специальными формами сами по себе.

во – вторых, поскольку эти сорутинги-это просто блоки итераторов, вы можете перебирать их сами, если хотите-вам не нужно, чтобы двигатель делал это за вас. Я использовал это для добавления условий прерывания в корутину раньше:

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

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

в-третьих, тот факт, что вы можете передать на другой сопрограммы могу позволить вам реализовать свой собственный YieldInstructions, пусть и не так performantly, как если бы они были реализованы двигателя. Например:

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

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

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

тем не менее, я бы не рекомендовал это – стоимость запуска Coroutine немного тяжеловата на мой вкус.

вывод Я надеюсь, что это немного прояснит некоторые из того, что действительно происходит, когда вы используете корутину в Unity. Блоки итераторов C# - это заводная маленькая конструкция, и даже если вы не используете Unity, возможно, вы находите полезным воспользоваться ими таким же образом.


первый заголовок ниже-это прямой ответ на вопрос. Два заголовка после этого более полезны для повседневного программиста.

возможно, скучные детали реализации Coroutines

Coroutines объясняются в Википедия и в другом месте. Здесь я просто приведу некоторые детали с практической точки зрения. IEnumerator, yield, etc. are особенности языка C#, которые используются для несколько иных целей в Единство.

проще говоря, an IEnumerator утверждает, что имеет коллекцию значений, которые вы можете запросить один за другим, как List. В C# функция с сигнатурой для возврата IEnumerator на самом деле не нужно создавать и возвращать его, но может позволить C# предоставить неявное IEnumerator. Затем функция может предоставить содержимое возвращаемого IEnumerator в будущем в ленивой манере, через yield return заявления. Каждый раз, когда абонент запрашивает другое значение из этого неявное IEnumerator функция выполняется до следующего yield return оператор, который предоставляет следующее значение. В качестве побочного продукта этого функция приостанавливается до тех пор, пока не будет запрошено следующее значение.

в Unity мы не используем их для предоставления будущих значений, мы используем тот факт, что функция приостанавливается. Из-за этой эксплуатации, много вещей, о сопрограммах в единстве не имеет смысла (что значит IEnumerator вообще? Что такое yield? Почему?--12-->? так далее.). Что происходит "под капотом" - это значения, которые вы предоставляете через IEnumerator, используются StartCoroutine() чтобы решить, когда запрашивать следующее значение,которое определяет, когда ваш coroutine снова отключится.

ваша игра Unity однопоточная ( * )

Coroutinesне потоки. Существует один основной цикл Unity, и все те функции, которые вы пишете, вызываются одним и тем же основным потоком по порядку. Вы можете проверить это, разместив while(true); в любом из ваших функции или сопрограммы. Это заморозит все, даже редактор Unity. Это свидетельствует о том, что все работает в одном основном потоке. этой ссылке то, что Кей упомянул в своем комментарии выше, также является отличным ресурсом.

(*) Unity вызывает ваши функции из одного потока. Таким образом, если вы не создаете поток самостоятельно, код, который вы написали, однопоточный. Конечно, Unity использует другие потоки, и вы можете создавать потоки самостоятельно, если хотите.

A Практическое описание Coroutines для игровых программистов

в основном, когда вы называете StartCoroutine(MyCoroutine()), это так же, как обычный вызов функции MyCoroutine() до первого yield return X, где X что-то вроде null, new WaitForSeconds(3), StartCoroutine(AnotherCoroutine()), break, etc. Это когда он начинает отличаться от функции. Unity "приостанавливает" эту функцию прямо на этом yield return X линия, идет дальше с другим делом и некоторые кадры проходят, и когда пришло время снова, Unity возобновляет эту функцию справа после этой строки. Он запоминает значения для всех локальных переменных в функции. Таким образом, вы можете иметь for цикл, который проходит каждые две секунды, например.

когда Unity возобновит вашу корутину, зависит от того, что X в своем yield return X. Например, если вы использовали yield return new WaitForSeconds(3);, он возобновляется через 3 секунды. Если вы использовали yield return StartCoroutine(AnotherCoroutine()), оно возобновляется после AnotherCoroutine() полностью сделано, что позволяет вам гнездиться поведение во времени. Если вы просто использовали yield return null;, он возобновляется прямо на следующем кадре.


Он не может быть проще:

Unity (и все игровые движки) являются рамки.

двигатель делает "каждый кадр" для вас. (анимирует, отображает объекты, делает физику и так далее.)

- возможно, спросите вы .. - О, это замечательно. Что, если я хочу, чтобы двигатель делал что-то для меня каждый кадр? Как сказать двигатель сделать такой-в кадр?"

ответ ...

именно для этого и существует" корутина".

все просто.

и рассмотреть этот вопрос....

вы знаете функцию" Update". Проще говоря, все, что вы кладете туда, делается каждый кадр. Это буквально то же самое, без каких-либо отличий от синтаксиса корутина.

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;
 }

нет абсолютно никакой разницы.

сноска: как все указал, единство просто нет темы. "Фреймы" в Unity или в любом игровом движке совершенно не связаны с потоками.

Coroutines / yield-это просто способ доступа к кадрам в Unity. Вот и все. (И действительно, это абсолютно то же самое, что и функция Update (), предоставляемая Unity. Вот и все, все так просто.


копаться в этом в последнее время, написал сообщение здесь -http://eppz.eu/blog/understanding-ienumerator-in-unity-3d/ - это проливает свет на внутренние органы (с плотными примерами кода), лежащие в основе IEnumerator интерфейс, и как он используется для сопрограмм.

использование перечислителей коллекции для этой цели по-прежнему кажется мне немного странным. Это обратная сторона того, для чего предназначены счетчики. Смысл счетчиков-это возвращаемое значение при каждом доступе, но точкой сопрограмм является код между возвращаемыми значениями. Фактическое возвращаемое значение в этом контексте бессмысленно.