Название статьи - это вопрос, который мне задали на собеседовании на позицию Middle. В этой статье мы расмотри корутины в Unity, что они из себя представляют, и заодно захватим тему Enumerator\Enumerable в С# и небольшую тайну foreach. Статья должна быть очень полезной для начинающих и интересной для разработчиков с опытом.
И так, как всем известно, метод, представляющего из себя Coroutine в Unity, выглядит следующим образом:
IEnumerator Coroutine() { yield return null; }
Немного информации об корутинах в Unity и IEnumerator
В качестве возвращаемого объекта после yield return может быть:
new WaitForEndOfFrame() - останавливает выполнение до конца следующего кадра
new WaitForFixedUpdate() - останавливает выполнение до следующего кадра физического движка.
new WaitForSeconds(float x) - останавливает выполнение на x секунд игрового времени (оно может быть изменено через Time.timeScale)
new WaitForSecondsRealtime(float x) - останавливает выполнение на x секунд реального времени
new WaitUntil(Func<bool>) - останавливает выполнение до момента, когда Func не вернет true
new WaitWhile(Func<bool>) - обратное к WaitUntil, продолжает выполнение, когда Func возвращает false
null - то же, что и WaitForEndOfFrame(), но выполнение продолжается в начале след. кадра
break - завершает корутину
StartCoroutine() - выполнение останавливает до момента, когда новая начатая корутина не закончится.
Запускаются корутины через StartCoroutine(Coroutine()).
Корутины не являются асинхронными, они выполняются в основном потоке приложения, в том же, что и отрисовка кадров, инстансирование объектов и т.д., если заблокировать поток в корутине, то остановится все приложение, корутины с асинхронностью использовали бы "IAsyncEnumerator", который Unity не поддерживает. Корутина позволяет растянуть выполнение на несколько кадров, что бы не нагружать 1 кадр большими вычислениями. Unity предоставляет тип UnityWebRequest для Http запросов, которые можно выполнять "асинхронно" в несколько кадров, что может показаться "асинхронностью", на самом деле же это обертка над нативным асинхронным HttpClient, которая предоставляет некоторую информацию синхронно, по типу поля isDone, которое отображает - закончился ли запрос или еще ожидается ответ, но сам запрос идет асинхронно.
IEnumerator - это стандартная реализация паттерна "итератор" в C#, которая содержит синтаксический сахар для хранения состояния. (спасибо @SadOceanза дополнение)
Он возвращает IEnumerator и имеет необычный return с припиской yield.
yield return это составляющая IEnumerator, эта связка преобразуется, при компиляции, в машину состояний, которая сохраняет позицию в коде, ждет команды MoveNext от IEnumerator и продолжает выполнение до следующего yield return или конца метода, больше подробной информации можно найти на сайте майкрософта.
Интерфейс IEnumerator, в свою очередь, содержит следующие элементы:
public interface IEnumerator { object Current { get; } bool MoveNext(); void Reset(); }
Под капотом Unity это обрабатывается примерно так: Unity получает IEnumerator, который передается через StartCoroutine(IEnumerator), сразу вы��ывает MoveNext, для того, чтобы код дошел до первого yield return, здесь стоит уточнить, что при вызове такого метода выполнение кода внутри метода не начинается самостоятельно, и необходимо вызвать MoveNext, это можно проверить простым скриптом, который представлен под этим абзатцем, а затем если Unity получает в Current объект типа YieldInstruction, то выполняет инструкцию и снова вызывает MoveNext, то есть, метод может возвращать любой тип, и если это не YieldInstruction, то Unity его обработает как yield return null.
private IEnumerator _coroutine; // Start is called before the first frame update void Start() { _coroutine = Coroutine(); } // Update is called once per frame void Update() { if (Time.time > 5) _coroutine.MoveNext(); } IEnumerator Coroutine() { while (true) { Debug.Log(Time.time); yield return null; } }

Отлично, мы разобрали основной момент, а именно, что такое IEnumerator и как он работает. Теперь разберем такой случай:
Опишем класс, который наследует интерфейс IEnumerator
class TestEnumerator : IEnumerator { public object Current => new WaitForSeconds(1); public bool MoveNext() { Debug.Log(Time.time); return true; } public void Reset() { } /// Этот класс равносилен следующей корутине: /// IEnumerator Coroutine() /// { /// while(true){ /// Debug.Log(Time.time); /// yield return new WaitForSeconds(1); /// } /// } }
И мы теперь его можем использовать следующим способом:
void Start() { StartCoroutine(new TestEnumerator()); }

И так, мы рассмотрели IEnumerator и корутины, здесь можно еще долго рассматривать разные варианты использования, но в корне остается передача IEnumerator в каком либо виде в метод StartCoroutine.
Теперь предлагаю рассмотреть IEnumerable, этот интерфейс наследуют нативный массив C#, List из System.Generic и прочие подобные типы, вся его суть заключается в том, что он содержит метод GetEnumerator, который возвращает IEnumerator:
public interface IEnumerable { [DispId(-4)] IEnumerator GetEnumerator(); }
Реализуем простенький пример:
class TestEnumerable : IEnumerable { public IEnumerator GetEnumerator() { return new TestEnumerator(); } }
И теперь, мы можем сделать следующее:
IEnumerator Coroutine() { foreach (var delay in new TestEnumerable()) { Debug.Log($"{Time.time}"); yield return delay; } }

Применений для этого множество, например можно добавить в TestEnumerator случайное время задержки:
class TestEnumerator : IEnumerator { public object Current => new WaitForSeconds(_currDelay); private float _currDelay; public bool MoveNext() { _currDelay = Random.Range(1.0f, 3.0f); return true; } public void Reset() { } }

И немного магии для начинающих: foreach не требует чтобы объект возвращаемый GetEnumerator реализовывал IEnumerable, самое главное, что бы тип после "in" имел метод GetEnumerator(), и возвращал тип с свойством Current и методом MoveNext(), то есть, мы можем сделать так:
class TestEnumerator // Здесь было наследование от IEnumerator { public object Current => new WaitForSeconds(_currDelay); private float _currDelay; public bool MoveNext() { _currDelay = Random.Range(1.0f, 3.0f); return true; } // Здесь был Reset из IEnumerator, он теперь не нужен :) } class TestEnumerable // Здесь было наследование от IEnumerable { // Возвращаемый тип был IEnumerator public TestEnumerator GetEnumerator() { return new TestEnumerator(); } }
Как видно, нигде нет наследования и любого упоминания IEnumerable и IEnumerator, но при этом мы так же можем использовать следующий код:
IEnumerator Coroutine() { foreach (var delay in new TestEnumerable()) { Debug.Log($"{Time.time}"); yield return delay; } }

И так, разобрав корутины, IEnumerator, IEnumerable и foreach нужно бы увидеть пример использования этих знаний на практике:
Более удобные в использовании Coroutine
Здесь я хотел описать реализацию корутины с токеном отмены (CancelationToken) и событиями старта\завершения с возможность ставить выполнение на паузу, но я опоздал, и на github есть готовое решение, советую к изучение, хоть я и не полностью согласен с реализацией:
unity-task-manager/TaskManager.cs at master · AdamRamberg/unity-task-manager (github.com)
Буду благодарен критике и замечаниям, так же советую посмотреть другие мои статьи.