Название статьи - это вопрос, который мне задали на собеседовании на позицию 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;
  }
}
В логе видно, что впервые метод вызвался на 5-ой секунде, согласно условию в Update()
В логе видно, что впервые метод вызвался на 5-ой секунде, согласно условию в Update()

Отлично, мы разобрали основной момент, а именно, что такое 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 остался Debug в методе MoveNext.
Можно видеть, что там дважды выводится время в лог, это из-за того, что у нас в TestEnumerator остался Debug в методе MoveNext.

Применений для этого множество, например можно добавить в 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)

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

Only registered users can participate in poll. Log in, please.
Знали ли Вы, что foreach не требует реализации интерфейсов IEnumerator и IEnumerable?
53.54%Нет.53
46.46%Да, знал!46
99 users voted. 5 users abstained.