Введение
Когда речь заходит об асинхронных операциях в 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:
Стек вызовов при возникновении ошибки в async методе:
В случае с coroutine мы видим, что ошибка произошла в методе CreatePlayer
, но непонятно, кто вызвал этот метод. Хорошо, если метод CreatePlayer
вызывается только в одном месте, тогда проследить всю цепочку вызовов не составит труда, а если он вызывается из нескольких мест? В случае с async/await мы сразу видим всю цепочку вызовов, где у нас потенциально может быть проблема, что здорово экономит время при поиске ошибок.
8. Allocation & Performance
Ну и последний в списке, но не последний по значимости, пункт про производительность и использование памяти. Как уже упоминалось выше, UniTask использует структуры для задач и кастомный AsyncMethodBuilder, для достижения zero allocation. Также UniTask не использует ExecutionContext и SynchronizationContext, в отличии от Task, что позволяет добиться высокой производительности в Unity, так как исключает накладные расходы на переключение контекстов.
Я не буду здесь углубляться во все тонкости касательно производительности и использования памяти, для этого лучше почитать статью от самого автора на Medium, а приведу только результаты тестирования.
Так как тестирование производилось в редакторе Unity, AsyncStateMachine генерируемая комплятором C# это класс, поэтому мы видим выделения памяти при использовании UniTask. В релизном билде, AsyncStateMachine будет структурой и память выделяться не будет. Но даже несмотря на это, UniTask выделяет памяти существенно меньше чем Coroutine и Task.
Репозиторий с тестами производительности можно найти на GitHub. Убедитесь только, что используется последняя версия UniTask.
Заключение
Надеюсь перечисленных пунктов достаточно, чтобы вы посмотрели на использование async/await в Unity по новому, и начали рассматривать как альтернативу coroutine.
Пример использования библиотеки UniTask в проекте, можно найти на моём GitHub. Там же можно найти список источников, который поможет составить полную картину того, как работает async/await в C#.
Если интересно разобраться в теме async/await подробнее, предлагаю ознакомиться со второй статьёй из этой серии. Про восемь ошибок при использовании Async.
P.S. Буду рад любым комментариям, дополнениям и конструктивной критике.