Название статьи - это вопрос, который мне задали на собеседовании на позицию 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)

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

Комментарии (3)


  1. SadOcean
    13.08.2022 14:24
    +2

    Спасибо, хорошая статья, узнал кое-что новое.

    Хотел бы дополнить две вещи - на вопросы в заголовке статья не ответила:

    1 - Что такое корутины?
    Это механизм для запуска асинхронных методов, которые позволяют удобно описывать длительные (в несколько кадров) операции. Это удобно для процедурных анимаций.

    2 - При чем тут IEnumerator?
    IEnumerator - это стандартная реализация паттерна "итератор" в C#, которая содержит синтаксический сахар для хранения состояния.
    Unity использует этот механизм в необычных целях из-за сахара - состояние итератора используется как состояние асинхронной операции.

    Правильной функциональностью для этого в современном C# является механизм асинхронных функций с async/await (он буквально для этого предназначен), который в определенном смысле работает похоже. Видимо в Unity создали корутины когда async/await не было.
    Соответственно можно найти библиотеки для реализации корутин на async/await


    1. AlexMorOR Автор
      13.08.2022 19:29

      Искренне рад, что вам понравилась статья!

      Добавлю ваши замечания, но с небольшими корректировками:

      1. Корутины не являются асинхронными, они выполняются в основном потоке приложения, в том же, что и отрисовка кадров, инстансирование объектов и т.д., если заблокировать поток в корутине, то остановится все приложение, корутины с асинхронностью использовали бы "IAsyncEnumerator", который Unity не поддерживает. Но вы правы, что корутина позволяет растянуть выполнение на несколько кадров, что бы не нагружать 1 кадр большими вычислениями. Unity предоставляет тип UnityWebRequest для Http запросов, которые можно выполнять "асинхронно" в несколько кадров, что может показаться "асинхронностью", на самом деле же это обертка над нативным асинхронным HttpClient, которая предоставляет некоторую информацию синхронно, по типу поля isDone, которое отображает - закончился ли запрос или еще ожидается ответ, но сам запрос идет асинхронно.

        Поэтому Unity корутины, в рамках программирования, не реализуют асинхронность.

      2. async\await существует с C# 5, которая, согласно гуглу, вышла в 2012 году, вряд ли за 10 лет команда Unity не обновила бы свой движек, учитывая, что появление async\await было самым главным событием для языка, поэтому смысл корутин в другом, а именно - разделить выполнение на несколько кадров в основном потоке, что написано в документации.

      Применение для асинхронных корутин мне сложно придумать, если есть Async\Await.


      1. SadOcean
        14.08.2022 01:48

        Я имел в виду асинхронность в широком смысле - корутины в таком случае вполне асинхронный механизм.

        Насколько я понимаю, механизм async/await не обязывает использовать многопоточность (хотя предназначен в том числе для ожидания выполнения задач в других потоках и простой их синхронизации в основной)
        В node.js операции асинхронные, хотя работают в контексте одного потока (конкретные io операции при этом могут выполнятся и в других потоках, зависит от реализации)

        Существует уже несколько библиотек, реализующих функциональность корутин на async/await
        Они просто предоставляют готовые асинхронные методы для главного потока, имитирующие поведение корутин - WaitForSeconds или WaitForNextFrame
        https://habr.com/ru/post/652483/

        Только они не стандартные и не включены в поставку от самой Unity, хотя по идее, являются более правильным сахаром для этого.