Как стать автором
Обновить

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

Время на прочтение9 мин
Количество просмотров38K

Введение

Когда речь заходит об асинхронных операциях в 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 и т.д.

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

И перед тем как начнём, хочу ещё раз обратить ваше внимание, что coroutine в Unity – это не совсем про асинхронность в привычном её понимании. Запущеные сопрограммы работают в основном потоке Unity. По факту это механизм, позволяющий выполнить код не в одном кадре, а растянуть выполнение на несколько кадров. И, как я упомянул выше, UniTask работает по схожему принципу, но позволяет при этом использовать ключевые слова async и await. Поэтому, используя async/await и UniTask, мы можем обращаться к Unity API без каких либо проблем, в отличие от стандартного механизма Task, который, как правило, выполняется в отдельном потоке. Да, в UniTask тоже можно запускать выполнение кода в отдельном потоке, но это тема уже отдельной статьи.

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 UniTaskVoid 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 UniTaskVoid 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 UniTaskVoid 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 UniTaskVoid 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 UniTask 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 в асинхронный метод, является хорошей практикой, и желательно предусмотреть его использование в этих методах.

CancellationToken можно получить используя CancellationTokenSource или используя метод расширения GetCancellationTokenOnDestroy у любого MonoBehaviour класса.

private void Start()
{
   var cancellationToken = this.GetCancellationTokenOnDestroy();

   StartOperationAsync(cancellationToken).Forget();
}

В Unity с версии 2022.2 все MonoBehaviour классы содержат destroyCancellationToken.

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, так как исключает накладные расходы на переключение контекстов.

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

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

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

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

Заключение

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

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

Если интересно разобраться в теме async/await подробнее, предлагаю ознакомиться со второй статьёй из этой серии. Про восемь ошибок при использовании Async.

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

Теги:
Хабы:
Всего голосов 10: ↑9 и ↓1+10
Комментарии25

Публикации

Истории

Работа

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань