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

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

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

Ответ 1

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


Подробное описание Unity3D

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

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

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

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

Единство - наряду с рядом других сред и языков - обеспечивает это в виде Corouts.

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

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

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

В С#:

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

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

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

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

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

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

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

Теперь, это трюк. Не имеет значения, каковы фактические значения, возвращаемые последовательностью. Вы можете повторно вызвать MoveNext() и игнорировать 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; }
}

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

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

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

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

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 для обработки других случаев - например, поддержка уровня сигнала на уровне двигателя может быть добавлена ​​с поддержкой YieldInstruction WaitForSignal (SignalName). Добавляя больше YieldInstructions, сами сопрограммы могут стать более выразительными - урожайность  return new WaitForSignal ( "GameOver" ) лучше читать тогда (! Signals.HasFired( "GameOver" ))  return return null, если вы спросите меня, совершенно независимо от того, что делать это в движке можно быстрее, чем делать это в script.

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

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

YieldInstruction y;

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

yield return y;

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

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

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

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

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

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 немного тяжела для меня.

Заключение Надеюсь, это немного разъяснит некоторые из того, что действительно происходит, когда вы используете Coroutine в Unity. Cтераторные блоки С# представляют собой небольшую конструкцию groovy, и даже если вы не используете Unity, возможно, вам будет полезно использовать их таким же образом.

Ответ 2

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

Возможно, сверлильная информация о выполнении команд Coroutines

Coroutines объясняются в Wikipedia и в другом месте. Здесь я просто приведу некоторые детали с практической точки зрения. IEnumerator, yield и т.д. Функции языка С#, которые используются в какой-то другой цели в Unity.

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

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

Ваша игра Единства Единственная Резьба (*)

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

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

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

В принципе, когда вы вызываете StartCoroutine(MyCoroutine()), он точно напоминает обычный вызов функции MyCoroutine(), до первого yield return X, где X - это что-то вроде null, new WaitForSeconds(3), StartCoroutine(AnotherCoroutine()), break и т.д. Это когда он начинает отличаться от функции. 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;, он возобновляется прямо в следующем кадре.

Ответ 3

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

Unity (и все игровые движки) основаны на фреймах.

Весь смысл, весь смысл Единства, заключается в том, что он основан на рамах. Двигатель делает для вас "каждый кадр". (Анимация, рендеринг объектов, физика и т.д.)

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

Ответ...

Именно это и есть "сопрограмма".

Это просто так просто.

И рассмотрим это....

Вы знаете функцию "Обновить". Совершенно просто, все, что вы там вставляете, выполняется каждый кадр. Это в буквальном смысле то же самое, никакой разницы вообще, из синтаксиса coroutine-yield.

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 просто не имеет потоков. "Кадры" в Unity или в любом игровом движке никак не связаны с потоками.

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

Ответ 4

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

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