Unity: 8 причин отказаться от Coroutine в пользу Async

в 11:31, , рубрики: async, C#, coroutine, unitask, unity, разработка игр

Введение

Когда речь заходит об асинхронных операциях в Unity, на ум первым делом приходит coroutine. И это не удивительно, так как большинство примеров в сети реализованы именно через них. Но мало кто знает, что Unity поддерживает работу с async/await еще с 2017 версии.

Так почему же большинство разработчиков до сих пор использует coroutine вместо async/await? Во первых, как я уже упомянул, большая часть примеров написана с использованием coroutine. Во вторых, async/await кажется очень сложным для начинающих разработчиков. Ну и в третьих, когда речь заходит о коммерческих проектах, где основным из критериев является стабильность, предпочтение отдается проверенному годами подходу.

Но технологии не стоят на месте и появляются библиотеки, которые делают работу с async/await в Unity удобной, стабильной и самое главное высокопроизводительной. И говорю я о библиотеке UniTask.

Я не буду перечислять все преимущества этой библиотеки, а выделю только основные:

  • Использует структуры для задач и кастомный AsyncMethodBuilder для достижения zero allocation

  • Позволяет использовать ключевое слово await со всеми Unity AsyncOperations и Coroutine

  • Не использует потоки и полностью работает на Unity PlayerLoop, что позволяет использовать async/await в WebGL, Wasm и т.д.

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

P.S. Код из последующих пунктов, приведен в качестве примера и может содержать ошибки. Не копируйте его бездумно в свой продукт.

1. Has return value

Coroutine не могут возвращать значения. Поэтому, если необходимо получить результат из метода, используется callback типа Action<T>, либо приведение IEnumerator.Current к необходимому типу, после завершения coroutine, но эти подходы, как минимум, неудобны в использовании и подвержены ошибкам.

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

С использованием coroutine подобное можно реализовать так:

private IEnumerator Start()
{
   yield return DownloadImageCoroutine(_imageUrl, texture =>
   {
       _image.texture = texture;
   });
}

private IEnumerator DownloadImageCoroutine(string imageUrl,
   Action<Texture2D> callback)
{
   using var request = UnityWebRequestTexture.GetTexture(imageUrl);

   yield return request.SendWebRequest();

   callback?.Invoke(request.result == UnityWebRequest.Result.Success
       ? DownloadHandlerTexture.GetContent(request)
       : null);
}

То же самое с использованием async/await делается вот так:

private async void Start()
{
   _image.texture = await DownloadImageAsync(_imageUrl);
}

private async UniTask<Texture2D> DownloadImageAsync(string imageUrl)
{
   using var request = UnityWebRequestTexture.GetTexture(imageUrl);

   await request.SendWebRequest();

   return request.result == UnityWebRequest.Result.Success
       ? DownloadHandlerTexture.GetContent(request)
       : null;
}

В реализации через async/await нет необходимости использовать callback и такой код читается легче. Так что если вы устали от постоянных callback’ов, то async/await ваш выбор.

2. Parallel processing

А теперь представим, что необходимо загрузить n изображений, и сделать это параллельно.

Решить подобную задачу с помощью coroutine можно так:

private IEnumerator Start()
{
   var textures = new List<Texture2D>();

   yield return WhenAll(_imageUrls.Select(imageUrl =>
   {
       return DownloadImageCoroutine(imageUrl, texture =>
       {
           textures.Add(texture);
       });
   }));

   for (var i = 0; i < textures.Count; i++)
   {
       _images[i].texture = textures[i];
   }
}

private IEnumerator WhenAll(IEnumerable<IEnumerator> routines)
{
   var startedCoroutines = routines.Select(StartCoroutine).ToArray();

   foreach (var startedCoroutine in startedCoroutines)
   {
       yield return startedCoroutine;
   }
}

Вот то же самое, реализованное с использованием async/await:

private async void Start()
{
   var textures = 
       await UniTask.WhenAll(_imageUrls.Select(DownloadImageAsync));

   for (var i = 0; i < textures.Length; i++)
   {
       _images[i].texture = textures[i];
   }
}

Из приведенных примеров видно, что используя coroutine необходимо самостоятельно реализовать метод WhenAll, в то время как UniTask предоставляет его из коробки, так же как и метод WhenAny. Попробуйте, на досуге, реализовать WhenAny используя coroutine, удивитесь как быстро возрастет сложность исходного кода.

3. Supports try/catch

Следующим преимуществом async/await перед coroutine является поддержка блока try/catch. Следовательно обернув наш код в try/catch, мы можем поймать и обработать ошибку в одном месте, где бы в стеке вызовов она не возникла. При попытке же обернуть yield return, компилятор выдаст ошибку.

Нельзя обернуть yield return в блок try/catch:

private IEnumerator Start()
{
   try
   {
       yield return ConstructScene(); // Compiler error!
   }
   catch (Exception exception)
   {
       Debug.LogError(exception.Message);
       throw;
   }
}

Используя async/await такой проблемы нет:

private async void Start()
{
   try
   {
       await ConstructScene();
   }
   catch (Exception exception)
   {
       Debug.LogError(exception.Message);
       throw;
   }
}

4. Always exits

В дополнение к предыдущему пункту, давайте посмотрим на блок try/finally.

Реализация используя coroutine:

private IEnumerator ShowEffectCoroutine(RawImage container)
{
   var texture = new RenderTexture(256, 256, 0);
   try
   {
       container.texture = texture;
       for (var i = 0; i < _frameCount; i++)
       {
           /*
            * Update effect.
            */
           yield return null;
       }
   }
   finally
   {
       texture.Release();
   }
}

Реализация используя async/await:

private async UniTask ShowEffectAsync(RawImage container)
{
   var texture = new RenderTexture(256, 256, 0);
   try
   {
       container.texture = texture;
       for (var i = 0; i < _frameCount; i++)
       {
           /*
            * Update effect.
            */
           await UniTask.Yield();
       }
   }
   finally
   {
       texture.Release();
   }
}

Приведенные примеры реализуют абсолютно одинаковую логику. Но в случае с coroutine, при ее остановке, возникновении исключения или удалении объекта на котором она была запущена, блок finally не будет достигнут. В реализации с использованием async/await такой проблемы нет и блок finally выполнится в любом случае, как от него и ожидается. Так что если у вас есть код использующий coroutine и блок try/finally, обратите на него внимание, возможно, у вас там утечка памяти.

5. Lifetime handled manually

Еще одним преимуществом async/await над coroutine является то, что для запуска асинхронной операции не нужен MonoBehaviour и вы сами контролируете ее жизненный цикл. Нет больше необходимости держать MonoBehaviour класс на сцене, единственной задачей которого является обеспечение работы запущенных coroutine.

Но с большими возможностями приходит и большая ответственность. Давайте посмотрим на следующий пример.

Реализация на coroutine:

private IEnumerator Start()
{
   StartCoroutine(RotateCoroutine());

   yield return new WaitForSeconds(1.0f);
   Destroy(gameObject);
}

private IEnumerator RotateCoroutine()
{
   while (true)
   {
       transform.Rotate(Vector3.up, 1.0f);
       yield return null;
   }
}

Реализация на async/await:

private async void Start()
{
   RotateAsync().Forget();

   await UniTask.Delay(1000);
   Destroy(gameObject);
}

private async UniTaskVoid RotateAsync()
{
   while (true)
   {
       transform.Rotate(Vector3.up, 1.0f);
       await UniTask.Yield();
   }
}

Как упоминалось выше, жизненный цикл async метода не зависит от MonoBehaviour. Следовательно после уничтожения объекта, в методе RotateAsync возникнет исключение MissingReferenceException, так как он продолжит выполняться, в то время как transform объекта, к которому мы обращаемся, уже не будет существовать. В случае же с coroutine, выполнение метода RotateCoroutine автоматически прекратиться, так как при удалении MonoBehaviour, все coroutine запущенные на нем останавливаются.

На самом деле, есть два подхода для решения этой задачи. Первый, остановить выполнение async метода передав в него CancellationToken, этот вариант подробнее разберем далее. Второй, самый логичный и правильный, просто вынести логику которая должна выполняться на каждом кадре, в Update. Зачем нам накладные расходы с созданием и поддержанием работы дополнительных объектов?

6. Full control

Как было сказано выше, так как жизненный цикл async метода не зависит от MonoBehaviour, у нас есть полный контроль над запущенной операцией. Чего нельзя сказать о coroutine.

Давайте разберем пример с реализацией механизма отмены асинхронной операции. Опустим все проверки и сконцентрируемся только на основной логике.

Используя coroutine, отмену обычно реализуют так:

public void StartOperation()
{
   _downloadCoroutine =
       StartCoroutine(DownloadImageCoroutine(_imageUrl, texture =>
       {
           _image.texture = texture;
       }));
}

public void CancelOperation()
{
   StopCoroutine(_downloadCoroutine);
}

private IEnumerator DownloadImageCoroutine(string imageUrl,
   Action<Texture2D> callback)
{
   var request = UnityWebRequestTexture.GetTexture(imageUrl);

   try
   {
       yield return request.SendWebRequest();

       callback?.Invoke(
           request.result == UnityWebRequest.Result.Success
               ? DownloadHandlerTexture.GetContent(request)
               : null);
   }
   finally
   {
       request.Dispose();
   }
}

Но внимательный читатель уже заметил проблему. Если у нас где-то в середине загрузки произойдет отмена операции, то блок finally не будет достигнут и Dispose не вызовется. Как же быть в данной ситуации?

Здесь на помощь может прийти CancellationToken:

public void StartOperation(CancellationToken token = default)
{
   StartCoroutine(DownloadImageCoroutine(_imageUrl, texture =>
   {
       _image.texture = texture;
   }, token));
}

private IEnumerator DownloadImageCoroutine(string imageUrl,
   Action<Texture2D> callback, CancellationToken token)
{
   var request = UnityWebRequestTexture.GetTexture(imageUrl);

   try
   {
       var asyncOperation = request.SendWebRequest();
       while (asyncOperation.isDone == false)
       {
           if (token.IsCancellationRequested)
           {
               request.Abort();
               yield break;
           }

           yield return null;
       }

       callback?.Invoke(
           request.result == UnityWebRequest.Result.Success
               ? DownloadHandlerTexture.GetContent(request)
               : null);
   }
   finally
   {
       request.Dispose();
   }
}

Уже лучше, теперь при отмене операции блок finally будет выполнен. Но мы все равно не застрахованы от деактивации объекта или удаления MonoBehaviour. Вот и получается, что над coroutine у нас нет полного контроля. В реализации же через async/await такой проблемы нет.

Реализация на async/await используя CancellationToken:

public async UniTaskVoid StartOperation(CancellationToken token = default)
{
   _image.texture = await DownloadImageAsync(_imageUrl, token);
}

private async UniTask<Texture2D> DownloadImageAsync(string imageUrl,
   CancellationToken token)
{
   var request = UnityWebRequestTexture.GetTexture(imageUrl);

   try
   {
       await request.SendWebRequest().WithCancellation(token);
  
       return request.result == UnityWebRequest.Result.Success
           ? DownloadHandlerTexture.GetContent(request)
           : null;
   }
   finally
   {
       request.Dispose();
   }
}

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

7. Preserves call stack

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

Стек вызовов при возникновении ошибки в coroutine:

Стек вызовов при возникновении ошибки в coroutine
Стек вызовов при возникновении ошибки в coroutine

Стек вызовов при возникновении ошибки в async методе:

Стек вызовов при возникновении ошибки в async методе
Стек вызовов при возникновении ошибки в async методе

В случае с coroutine мы видим, что ошибка произошла в методе CreatePlayer, но непонятно, кто вызвал этот метод. Хорошо, если метод CreatePlayer вызывается только в одном месте, тогда проследить всю цепочку вызовов не составит труда, а если он вызывается из нескольких мест? В случае с async/await мы сразу видим всю цепочку вызовов, где у нас потенциально может быть проблема, что здорово экономит время при поиске ошибок.

8. Allocation & Performance

Ну и последний в списке, но не последний по значимости, пункт про производительность и использование памяти. Как уже упоминалось выше, UniTask использует структуры для задач и кастомный AsyncMethodBuilder, для достижения zero allocation. Также UniTask не использует ExecutionContext и SynchronizationContext, в отличии от Task, что позволяет добиться высокой производительности в Unity, так как исключает накладные расходы на переключение контекстов.

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

Выделение памяти при использовании UniTask, Coroutine и Task
Выделение памяти при использовании UniTask, Coroutine и Task

Так как тестирование производилось в редакторе Unity, AsyncStateMachine генерируемая комплятором C# это класс, поэтому мы видим выделения памяти при использовании UniTask. В релизном билде, AsyncStateMachine будет структурой и память выделяться не будет. Но даже несмотря на это, UniTask выделяет памяти существенно меньше чем Coroutine и Task.

Репозиторий с тестами производительности можно найти тут. Убедитесь только, что используется последняя версия UniTask.

Заключение

Надеюсь перечисленных пунктов достаточно, чтобы вы посмотрели на использование async/await в Unity по новому, и начали рассматривать как альтернативу coroutine.

Пример использования библиотеки UniTask в проекте, можно найти здесь. Там же можно найти список источников, который поможет составить полную картину того, как работает async/await в C#.

Я сейчас готовлю статью о самых распространенных ошибках при использовании async/await, так что если интересно подробнее разобраться в этой теме, следите за обновлениями.

P.S. Буду рад любым комментариям, дополнениям и конструктивной критике.

Автор:
Dmitry9192

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js