Название статьи - это вопрос, который мне задали на собеседовании на позицию 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)
Буду благодарен критике и замечаниям, так же советую посмотреть другие мои статьи.
SadOcean
Спасибо, хорошая статья, узнал кое-что новое.
Хотел бы дополнить две вещи - на вопросы в заголовке статья не ответила:
1 - Что такое корутины?
Это механизм для запуска асинхронных методов, которые позволяют удобно описывать длительные (в несколько кадров) операции. Это удобно для процедурных анимаций.
2 - При чем тут IEnumerator?
IEnumerator - это стандартная реализация паттерна "итератор" в C#, которая содержит синтаксический сахар для хранения состояния.
Unity использует этот механизм в необычных целях из-за сахара - состояние итератора используется как состояние асинхронной операции.
Правильной функциональностью для этого в современном C# является механизм асинхронных функций с async/await (он буквально для этого предназначен), который в определенном смысле работает похоже. Видимо в Unity создали корутины когда async/await не было.
Соответственно можно найти библиотеки для реализации корутин на async/await
AlexMorOR Автор
Искренне рад, что вам понравилась статья!
Добавлю ваши замечания, но с небольшими корректировками:
Корутины не являются асинхронными, они выполняются в основном потоке приложения, в том же, что и отрисовка кадров, инстансирование объектов и т.д., если заблокировать поток в корутине, то остановится все приложение, корутины с асинхронностью использовали бы "IAsyncEnumerator", который Unity не поддерживает. Но вы правы, что корутина позволяет растянуть выполнение на несколько кадров, что бы не нагружать 1 кадр большими вычислениями. Unity предоставляет тип UnityWebRequest для Http запросов, которые можно выполнять "асинхронно" в несколько кадров, что может показаться "асинхронностью", на самом деле же это обертка над нативным асинхронным HttpClient, которая предоставляет некоторую информацию синхронно, по типу поля isDone, которое отображает - закончился ли запрос или еще ожидается ответ, но сам запрос идет асинхронно.
Поэтому Unity корутины, в рамках программирования, не реализуют асинхронность.
async\await существует с C# 5, которая, согласно гуглу, вышла в 2012 году, вряд ли за 10 лет команда Unity не обновила бы свой движек, учитывая, что появление async\await было самым главным событием для языка, поэтому смысл корутин в другом, а именно - разделить выполнение на несколько кадров в основном потоке, что написано в документации.
Применение для асинхронных корутин мне сложно придумать, если есть Async\Await.
SadOcean
Я имел в виду асинхронность в широком смысле - корутины в таком случае вполне асинхронный механизм.
Насколько я понимаю, механизм async/await не обязывает использовать многопоточность (хотя предназначен в том числе для ожидания выполнения задач в других потоках и простой их синхронизации в основной)
В node.js операции асинхронные, хотя работают в контексте одного потока (конкретные io операции при этом могут выполнятся и в других потоках, зависит от реализации)
Существует уже несколько библиотек, реализующих функциональность корутин на async/await
Они просто предоставляют готовые асинхронные методы для главного потока, имитирующие поведение корутин - WaitForSeconds или WaitForNextFrame
https://habr.com/ru/post/652483/
Только они не стандартные и не включены в поставку от самой Unity, хотя по идее, являются более правильным сахаром для этого.