Введение

Когда речь заходит об асинхронных операциях в 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. Буду рад любым комментариям, дополнениям и конструктивной критике.

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


  1. BigObfuscator
    19.02.2022 15:22
    +4

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

    Токен генерируется через CancellationTokenSource. А оно в свою очередь должно быть объявлено полем в MonoBehaviour. И еще оно IDisposable, а значит его нужно корректно диспозить. А что бы его диспозить нужно вызвать его Dispose в событии OnDestroy. А вот это событие вызывается не всегда (и при краше приложения и в редакторе). В результате чего асинхронная операция продолжает себе спокойно выполняться дальше после того как основной поток уже умер или вышел из плей-мода.

    В общем, CancellationToken это боль. А вот с классической корутиной таких проблем нет. Вызвал StopCoroutine и все.

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

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

    В целом async/await это хорошо и правильно. Но вот проблема остановки таких корутин к сожалению останавливает от их использования.


    1. bezarius
      19.02.2022 17:13
      +6

      >Использование CancellationToken громоздко и неудобно.<

      Такой подход является стандартным для .NET мира. С таким же успехом можно критиковать в целом подход к работе с async\await в .NET и тасками.

      > И еще оно IDisposable, а значит его нужно корректно диспозить <

      При работе с файлами точно также нужно вручную освобождать ресурсы или вы и от этого инструмента отказались?

      >нужно вызвать его Dispose в событии OnDestroy. А вот это событие вызывается не всегда<

      Особенность работы жизненного цикла монобехов: "OnDestroy will only be called on game objects that have previously been active."

      > В результате чего асинхронная операция продолжает себе спокойно выполняться дальше после того как основной поток уже умер или вышел из плей-мода.<

      "Runs completely on Unity's PlayerLoop so doesn't use threads and runs on WebGL, wasm, etc."

      https://github.com/Cysharp/UniTask/blob/18f2746f0d30a1b870d9835f2f16d15b56476a33/src/UniTask/Assets/Plugins/UniTask/Runtime/PlayerLoopHelper.cs#L203

      >К тому же, зависимость корутины от MonoBehaviour вы почему-то записываете в минусы. Но на самом деле, привязка жизни корутины к жизни объекта сцены вполне логична и удобна.<

      Корутины могут не иметь ничего общего с объектами в сцене.

      >Если же нужно сделать непривязанную ни к чему корутину, то это делается легко через какой нибудь самописный Dispatcher который у всех давно уже есть. <

      Мне кажется не стоит говорить за всех. По моим наблюдениям в районе половины компаний используют UniRx\UniTask вместо того, что бы изобретать свои велосипеды.

      К слову, а всякие WhenAll\Any \ContinueWith и тп вы тоже будете велосипедить? =)


      1. BigObfuscator
        19.02.2022 18:29
        +1

        Такой подход является стандартным для .NET мира. С таким же успехом можно критиковать в целом подход к работе с async\await в .NET и тасками.

        Для .Net мира может и является стандартным, а для Unity мира - нет. В стандартном .Net не было корутин, вместо них появилось решение async/await. Но в юнити такое решение как корутина было всегда. И оно более удобно в этом плане.

        А вообще, я вам про то что это не удобно, а вы мне "это стандартное решение".

        При работе с файлами точно также нужно вручную освобождать ресурсы или вы и от этого инструмента отказались?

        При работе с файлами я использую using. Это удобно. А вот с CancellationTokenSource так не получится.
        К тому же, чтение файлов, так скажем, не самая частая операция в игровом движке.

        По моим наблюдениям в районе половины компаний используют UniRx\UniTask вместо того, что бы изобретать свои велосипеды. К слову, а всякие WhenAll\Any \ContinueWith и тп вы тоже будете велосипедить? 

        Ну хорошо, что используют.

        Еще раз: стандартную корутину можно запустить без привязки к объекту. А так-то, да, я люблю велосипеды :)


        1. bezarius
          19.02.2022 19:00
          +3

          >Для .Net мира может и является стандартным, а для Unity мира - нет <

          Ну во первых, Unity3d часть мира .NET, а во вторых, UniTask это реализация Task для Unity3d. По этому было бы странно, если бы реализация была иной.

          >Но в юнити такое решение как корутина было всегда. И оно более удобно в этом плане.<

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


          1. David_Niko
            21.02.2022 04:52

            >Ну во первых, Unity3d часть мира .NET, а во вторых, UniTask это реализация Task для Unity3d. По этому было бы странно, если бы реализация была иной.

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

            > когда с помощью этой штуки даже значение вернуть нельзя

            Как это нельзя, это же IEnumerator, yield return никто не отменял.


            1. bezarius
              21.02.2022 11:51

              >Как это нельзя, это же IEnumerator, yield return никто не отменял.<

              Ну покажите пример, тогда поговорим, на счет удобства=)


              1. dm_bondarev
                22.02.2022 14:32

                Coroutine<Texture> cor = StartCoroutine<Texture>(GetAvatarCor(url));
                yield return cor.coroutine;
                try {
                    texture = cor.Value;
                } catch (Exception e) {
                    throw new Exception(e.Message);
                }


  1. Tontu
    19.02.2022 16:03
    +2

    Ну все-таки совсем от юнитёвских корутин отказываться не оправдано. Привязка к монобехам даёт интуитивную и удобную инфраструктуру для сборки вьюх на корутинах (очень быстро можно сделать на ней state machine для управления состояниями представления), вместо использования Update. А вот внутрянку лучше действительно делать на async/await во всех остальных местах, где нет монобехов. Сеть однозначно туда отходит, вообще работа с общими ограниченными ресурсами типа файловых систем и пр. Что-то сродни подхода node.js.


  1. Wundt
    19.02.2022 18:01
    +2

    Мы всей командой перешли на UniTask'и и очень довольны.


  1. sergeymolchanovsky
    19.02.2022 22:28
    -5

    Вы демонстрируете в корне НЕВЕРНУЮ обработку ошибок:

    catch (Exception exception)
       {
           Debug.LogError(exception.Message);
           throw;
       }

    Мы в блоке try поймали ошибку, ура. В catch мы ее обрабатываем. Минимально, это вывод ошибки в консоль, а также это может быть зануление каких-то переменных или вывод на UI сообщения об ошибке для пользователя. Цель - ошибка обработана, программа корректно продолжает выполнение.

    Спрашивается, если мы уже поймали ошибку, зачем вы бросаете ещё раз? Если, чтобы получить сообщение в консоли, то это крайне хреновый способ, ибо есть Debug.LogError для такого.


    1. Dmitry9192 Автор
      20.02.2022 06:14
      +2

      Пример демонстрирует возможность обернуть асинхронный метод в блок try/catch. А как обработать ошибку уже зависит от разработчика. И вы правильно заметили, что кому-то достаточно просто в лог писать, а кому-то необходимо сообщение на экране показать.

      Поэтому конкретная реализация не приведена, а в начале статьи я предупреждаю, что весь код приведен только в качестве примера.


  1. overtest
    19.02.2022 22:42

    Одна из проблем UniTask при запуске в редакторе Unity в том, что после остановки игры асинхронная задача продолжает выполняться, что для тех кто привык работать с корутинами окажется неожиданностью. Пришлось немного подправить код библиотеки, чтобы остановка происходила сама без явного CancellationToken.


    1. Myxach
      20.02.2022 02:56

      Звучит как что-то, что работает неправильно


      1. overtest
        20.02.2022 12:16

        Немного неверно описал, UniTask отрабатывает еще 2 цикла после выхода редактора из Play-mode, что влечет разные ошибки при обращении к разрушенным объектам. А вот дефолтный Task продолжает работать бесконечно.

        Тестовый код:

        public class Test : MonoBehaviour
        {
            private void Start()
            {
                RunUniTask().Forget();
                RunTask();
            }
        
            private async UniTaskVoid RunUniTask()
            {
                while (true)
                {
                    await UniTask.Yield();
                    Debug.Log($"UniTask is playing: {Application.isPlaying}");
                }
            }
        
            private async void RunTask()
            {
                while (true)
                {
                    await Task.Yield();
                    Debug.Log($"Task is playing: {Application.isPlaying}");
                }
            }
        }

        Лог (UniTask успевает сработать 2 раза после остановки игры, Task продолжает работать все время):


  1. YehorK
    21.02.2022 04:48

    Спасибо автору за статью ????

    Подскажите, пожалуйста: правильно ли я понимаю, что и для Unity async/await эффективен, когда мы имеем дело с относительно медленными I/O операциями: прием-передача данных по сети, чтение-запись данных на HDD, тогда как для CPU-/GPU-/RAM-/VRAM-bound операций эффективней будет использовать coroutines, так как потери вычислительного времени на асинхронное переключение контекста могут лишь замедлить такие операции в сравнении с выполнением вычислений в coroutines из расчета "одна корутина на ядро"?