#️⃣🔄 Асинхронность в Unity: лучше или хуже, чем корутины?

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

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

Когда вы пишете код в Unity, обычно он исполняется сразу. Это означает, что каждый метод, который необходим в определенном кадре, обрабатывается до того, как кадр будет отрисован. Строка за строкой, метод за методом, как можно быстрее.

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

Обычно вы можете сделать это с помощью сопрограммы или корутины (Coroutine), которая позволяет разделить метод так, чтобы он работал в течение нескольких кадров, а не только одного. Однако же, несмотря на то, что корутины могут быть чрезвычайно полезны, у них также есть недостатки. Более того, хотя может показаться, что они выполняются отдельно от остального кода, на самом деле они являются синхронными операциями. Это означает, что если заблокировать поток в сопрогромме, то ваша игра остановится, пока корутина не завершится.

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

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

Но действительно ли асинхронность лучше, чем использование корутин?

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

Как использовать Async в Unity

Async и Await — это ключевые слова в C#, которые позволяют вам выполнять логику асинхронно.

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

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

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

Чтобы создать асинхронный метод, вам нужно добавить ключевое слово async перед возвращаемым типом метода.

async void DoSomething()
{
    // Тело метода
}

Однако этого недостаточно для создания асинхронного метода.

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

async void DoSomething()
{
    Debug.Log("Задержка в 3 секунды");
    await Task.Delay(3000);
    Debug.Log("3 секунды спустя");
}

Задача (Task) — это представление асинхронной операции.

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

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

Асинхронный метод будет обработан синхронно, как и все остальное, пока не встретит инструкцию с ключевым словом await, которое позволит ему работать в фоновом режиме

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

Это означает, что для использования async необходимо использовать await, а для использования await необходимо иметь задачу, которую нужно ожидать.

Как создать Task в Unity

Прежде чем вы сможете создать любую задачу, вам нужно добавить пространство имен System.Threading.Tasks в начало вашего скрипта.

using UnityEngine;
using System.Threading.Tasks;

После этого вы сможете создавать и использовать задачи. Класс Task представляет собой тип асинхронной операции. Важно то, что его ход выполнения отслеживается, что позволяет асинхронной функции ожидать завершения задачи, не останавливая всю остальную логику в потоке.

Один из вариантов использования задач – задержка выполнения текущего метода, как с использованием оператора yield в корутинах.

Например, цикл while продолжает выполняться до тех пор, пока его условие не перестанет быть истинным:

void CountToTen()
{
    int number = 0;

    while (number < 10)
    {
        number++;
        Debug.Log(number);
    }

    Debug.Log("Выполнение метода закончилось!");
}

В обычном методе вся эта логика происходит в течение одного кадра. Однако это может легко вызвать проблемы, если цикл содержит внутри себя сложную или ресурсозатратную логику. Это происходит потому, что Unity обрабатывает цикл while синхронно, то есть он не может делать ничего другого, пока условие цикла не перестанет быть истинным.

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

async void CountToTenAsync()
{
    int number = 0;

    while (number < 10)
    {
        number++;
        Debug.Log(number);
        await Task.Yield();
    }

    Debug.Log("Выполнение метода закончилось!");
}

Однако, что, если вы не хотите ждать следующего кадра? Что, если вы хотите подождать некоторое время вместо этого?

Как и в случае с корутиной, асинхронный метод можно приостановить на определенное время, после чего он продолжит выполнение с того места, где остановился в ожидании Task.Delay():

async void Wait()
{
    Debug.Log("Ждем одну секунду...");
    await Task.Delay(1000);
    Debug.Log("Готово!");
}

В отличие от метода WaitForSeconds() в корутине, Task.Delay() принимает длительность в миллисекундах, а не секундах. Это означает, что если вы хотите вместо этого передать секунды в параметр delay, вам нужно будет умножить количество секунд на 1000, например:

async void Wait() 
{ 
    // Ждем 2.5 секунды
    await Task.Delay((int)2.5 * 1000); 
}

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

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

async void Start()
{
    await MyFunction();
    Debug.Log("Готово!");
}

async Task MyFunction()
{
    // Ждем 5 секунд
    await Task.Delay(5000);
}

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

async void Start()
{
    await Task.Run(() => Count());
    Debug.Log("Готово!");
}

void Count()
{
    for (int i = 0; i < 10000; i++)
    {
        Debug.Log(i);
    }
}

Это позволяет обычно синхронному блоку кода выполняться в фоновом режиме.

#️⃣ Библиотека шарписта
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека шарписта»

Когда следует использовать асинхронность в Unity

Главное преимущество асинхронных задач в Unity заключается в том, что, в отличие от обычных методов, они не блокируют основной поток.

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

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

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

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

Однако они работают по-разному и имеют разные сильные и слабые стороны.

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

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

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

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

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

void Start()
{
    StartCoroutine(MyTask());
    MyTaskAsync();
    Destroy(gameObject);
}

async void MyTaskAsync()
{
    // Метод завершится несмотря на уничтожение объекта
    Debug.Log("Работа асинхронного метода началась");
    await Task.Delay(5000);
    Debug.Log("Работа асинхронного метода завершилась");
}

IEnumerator MyTask()
{
    // Этот метод не завершится, так как игровой объект будет уничтожен, а корутина остановится
    Debug.Log("Корутина начала работу");
    yield return new WaitForSeconds(5);
    Debug.Log("Корутина завершила работу"); // Эта строка не будет вызвана
}

Это может быть хорошо или плохо, в зависимости от того, что вы пытаетесь сделать.

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

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

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

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

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

#️⃣🧩 Библиотека задач по C#
Интересные задачи по C# для практики можно найти на нашем телеграм-канале «Библиотека задач по C#»

Как отменить работу асинхронного метода в Unity

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

Это связано с тем, что для отмены асинхронного метода или, по крайней мере, той части метода, которая фактически является асинхронной, вам понадобится Cancellation Token.

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

Затем, если объект уничтожен, он может использовать CancellationTokenSource, чтобы также отменить задачу, которой был передан его токен, как это показано в примере:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Threading.Tasks;
using System.Threading;

public class AsyncExample : MonoBehaviour
{
    CancellationTokenSource cancellationTokenSource;

    void Start()
    {
        StartCoroutine(MyTask());
        MyTaskAsync();
        Destroy(gameObject);
    }

    private void OnDestroy()
    {
        cancellationTokenSource?.Cancel();
    }

    async void MyTaskAsync()
    {
        cancellationTokenSource = new CancellationTokenSource();
        Debug.Log("Async Task Started on: " + gameObject);
        await Task.Delay(5000, cancellationTokenSource.Token);
        Debug.Log("Async Task Ended on: " + gameObject);
    }

    IEnumerator MyTask()
    {
        Debug.Log("Task Started");
        yield return new WaitForSeconds(5);
        Debug.Log("Task Ended on: " + gameObject);
    }
}

Несмотря на то что этот способ позволит отменить выполнение задачи, он вызовет исключение TaskCancelledException:

Отмена задачи приведет к ошибке, которую вам нужно будет обработать.

Чтобы исправить это, вам нужно использовать оператор try/catch.

Оператор try/catch позволяет заворачивать потенциально опасной код, перехватывать возникающие исключения и обрабатывать их. Метод попытается вызвать логику в блоке try, и, если возникнет исключение, в зависимости от типа исключения будет вызван блок catch. Это позволяет задаче выйти из метода, если будет поймано исключение, вместо того, чтобы пытаться продолжить.

async void MyTaskAsync()
{
    cancellationTokenSource = new CancellationTokenSource();
    Debug.Log("Async Task Started on: " + gameObject);
    try
    {
        await Task.Delay(5000, cancellationTokenSource.Token);
    }
    catch
    {
        Debug.Log("Task was cancelled!");
        return;
    }
    finally
    {
        cancellationTokenSource.Dispose();
        cancellationTokenSource = null;
    }
    Debug.Log("Async Task Ended on: " + gameObject);
}

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

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

#️⃣🎓 Библиотека C# для собеса
Подтянуть свои знания по C# вы можете на нашем телеграм-канале «Библиотека C# для собеса»

Что же лучше использовать: корутины или асинхронные методы?

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

Например, корутины больше подходят для задач типа «запустил и забыл», которые не требуют тщательного управления. Они хорошо подходят для тех случаев, когда требуется выполнить логику с небольшой задержкой. Иногда их используют для простых анимаций в пользовательском интерфейсе, хотя делать так не рекомендуется – для этого есть более изящные и эффективные способы. Их удобно вызывать в скриптах, которые размещаются на игровых объектах. В случае уничтожения этого объекта корутина сразу же завершится, что поможет избежать возникновения различных исключений.

При обработке ресурсоемкой задачи в фоновом режиме (например, загрузки уровня или ассетов), чтобы она не останавливала вашу игру во время выполнения, лучше использовать асинхронность. Также ее следует использовать для подключения к серверу, если вы взаимодействуете с сетью.

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

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

matyushkin
18 марта 2020

ТОП-10 книг по C#: от новичка до профессионала

Отобрали актуальные книги по C#, .NET, Unity c лучшими оценками. Расположил...
Библиотека программиста
25 августа 2019

Почему C# программисты скоро будут нарасхват

C# программисты становятся более востребованными благодаря развивающейся эк...
Библиотека программиста
23 июня 2017

Разработка игр – это просто: 12 этапов изучения геймдева

Разработка игр на плаву, она перспективна и набирает популярность. Мы подго...