Как startcoroutine / yield return pattern действительно работает в Unity?


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

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

4 98

4 ответа:

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


Unity3D сопрограммы в деталях

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

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

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

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

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

как они выглядят? В "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. Я никогда не видел мужества из сопрограммного двигателя Unity. Однако, если они реализовали его таким образом, который радикально отличается от того, что я собираюсь описать, то я буду очень удивлен. Если кто-то из UT хочет перезвонить и поговорить о том, как это на самом деле работает, то это было бы здорово.

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

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

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

An блок итератора-это регулярная функция, которая (a) возвращает IEnumerator и (b) использует ключевое слово yield. Так что же на самом деле делает ключевое слово yield? Он объявляет следующее значение в последовательности - или что больше нет значений. Точка, в которой код встречает выход return X или yield break-это точка, в которой находится IEnumerator.Метод MoveNext() следует прекратить; урожай возвращения X вызывает метод MoveNext (), чтобы возвратить True andCurrent быть присвоено значение Х, при урожайности перерыв вызывает 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; }
}

все дело в сроках Как вы видели, каждый оператор 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, сами сопрограммы могут стать более выразительными-yield возврат нового WaitForSignal ("GameOver") приятнее читать, чем while (!Сигналы.HasFired ("GameOver")) yield return null, если вы спросите меня, совсем не считая того, что делать это в движке можно было бы быстрее чем делать это в сценарии.

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

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

YieldInstruction y;

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

yield return y;

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

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

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);
  /* ... */
}

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

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

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

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

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

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

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

ваша игра Unity является однопоточным (*)

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

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

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

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

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

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

ответ ...

это именно то, что" сопрограмма " для.

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

и подумайте об этом....

вы знаете функцию "обновить". Довольно просто, все, что вы положили туда делается каждый кадр. Это буквально то же самое, никакой разницы вообще, от синтаксиса 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 или в любом игровом движке совершенно не связаны с потоками каким-либо образом.

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

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

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