Введение

Это вторая статья из серии про использование async/await в Unity. В первой мы разобрали восемь причин отказаться от Coroutine в пользу Async. Если ещё не знакомы, то рекомендую начать с неё. В данной же статье посмотрим на самые распространённые ошибки при использовании async/await. А завершить трилогию планируется детальным разбором отличий UniTask от Task. И почему же я рекомендую использовать именно UniTask. И, может, захватим там немного новомодный Awaitable, над которым Unity сейчас активно работают.

И перед тем, как начнём, буквально пару слов о том, как родилась эта серия статей. Чуть больше года назад мне предложили вести курс по разработке на Unity на одной популярной образовательной платформе. Когда я начал знакомиться с материалом, наткнулся там на следующий код.

public void DisplayDialogs(List<Dialog> dialogs)
{
    StartCoroutine(DialogCoroutine(dialogs));
}

private IEnumerator DialogCoroutine(List<Dialog> dialogs)
{
    var dialogTask = ShowDialogs(dialogs);

    while (!dialogTask.IsCompleted)
    {
        yield return null;
    }

    dialogTask.Wait(); // Убеждаемся, что возможные исключения обработаны.
}

private Task ShowDialogs(List<Dialog> dialogs)
{
    ...
}

Здесь автор, видимо, не смог определиться, какой же из подходов использовать – coroutine или async, и решил использовать сразу оба. Поэтому запускает coroutine для того, чтобы следить за статусом запущенной задачи dialogTask. Сама же асинхронная задача запускается просто вызовом метода ShowDialogs() без await. Судя по 15 строчке dialogTask.Wait(), автор знает, что такой вызов асинхронного метода "скроет" все исключения, возникшие в нём, поэтому вызывает метод Wait() чтобы всё-таки их увидеть, если они возникли. В общем, плохо здесь всё, начиная от пустой траты ресурсов процессора, которому теперь надо обрабатывать как Coriutine, так и Task, заканчивая полным непониманием, как работать с async/await. Даже ChatGPT видит здесь проблему и предлагает правильное решение, с которым можно ознакомиться здесь. Да, некоторые комментарии исходного кода там спорные, но конечный результат верный.

Дальше углубляться смысла не было, так как стало очевидно, что такой материал я преподавать не хочу, а времени на переписывание курса на тот момент у меня не было. В итоге было решено написать серию статей, раскрывающих нюансы работы с async/await в Unity и не только, дабы было меньше таких велосипедов в учебных материалах.

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

1. Use UniTask instead of Task

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

И так, помимо такого преимущества, как zero allocation, UniTask работает на основном потоке Unity, как и coroutine. Да, это не та асинхронность, которую предлагает Task с его потоками, но в большинстве случаев этого достаточно. К тому же никто не мешает использовать комбинацию этих подходов при необходимости.

Какие же преимущества нам даёт работа на основном потоке? Помимо таких очевидных, как вызов Unity API из async методов и работа в WebGL, есть два ключевых. Первый – UniTask не "скрывает" исключения, возникшие в async методах при неправильном их вызове, как это делает Task. Второй и самый важный – это невозможность получить взаимную блокировку «deadlock».

Резюмируя, UniTask минимизирует вероятность выстрелить себе в ногу при работе с async/await в Unity.

2. Async void

Давайте теперь разбираться с ошибками. И начнём мы с неустаревающей классики async void.

Так чем же плох следующий метод?

public async void SomeMethod()
{
    // Async operation.
}

Самая большая проблема приведённого кода в том, что если кто-то захочет вызвать метод SomeMethod() он даже и не узнает, что это асинхронный метод, пока не посмотрит его реализацию. И даже IDE об это не скажет.

Отсюда и вытекает первая проблема. Допустим, мы хотим обезопасить себя от исключений, которые могут возникнуть в методе SomeMethod(). Для этого мы оборачиваем его вызов в блок try/catch.

private void Awake()
{
    try
    {
        _class.SomeMethod();
    }
    catch (Exception e)
    {
        Debug.LogError(e.Message);
    }
}

Выглядит надёжно, не так ли? На самом деле нет. Если в методе SomeMethod() возникнет исключение, мы его никогда не поймаем. И если в Unity наша программа продолжает работать, хоть и с ошибками, то в большинстве приложений необработанное исключение, возникающие в async void методе, приводит к аварийному завершению работы приложения.

В C# async void методы обычно используются для сценариев «запустил и забыл». В идеале эти методы не должны быть блокирующими и, как правило, используются только в обработчиках событий или асинхронном коде верхнего уровня.

Давайте разберём приведённый пример детальнее, модифицировав его немного:

private void Awake()
{
    try
    {
        SomeMethod();
    }
    catch (Exception e)
    {
        Debug.LogError(e.Message);
    }
    
    print("Method end.");
}

private async void SomeMethod()
{
    print("Before exception.");
    throw new Exception();
}

Запустив этот код, в консоли мы увидим следующее:

  1. Before exception.

  2. Method end.

  3. Exception of type 'System.Exception' was thrown.

Первый вопрос, который возникает, глядя на лог: почему же наше исключение вывелось после Method end, а не до?

Для ответа на этот вопрос разберём шаг за шагом, что происходит:

  1. Когда мы вызываем метод SomeMethod() без await, он начинает выполняться асинхронно. Однако он по-прежнему работает в основном потоке Unity.

  2. Перед исключением выводится Before exception, как и ожидается.

  3. Затем генерируется исключение внутри SomeMethod() и начинает пробрасываться вверх по стеку вызовов.

  4. Поскольку мы вызвали SomeMethod() без ключевого слова await, метод Awake() продолжит своё выполнение и выведет Method end.

  5. Наконец, в консоль выводится сгенерированное исключение.

Из всего выше сказанного далаем вывод, что пока возникшее исключение в методе SomeMethod() дойдет до Awake() мы уже покинем не только блок try/catch, но и сам метод Awake().

Если разложить выполнение по кадрам, то будет такая картина:

  • Frame: 0 Before exception.

  • Frame: 0 Method end.

  • Frame: 0 OnEnable()

  • Frame: 1 Start()

  • Frame: 1 Update()

  • Frame: 1 Exception of type 'System.Exception' was thrown.

  • Frame: 1 LateUpdate()

Здесь видно, что бросили мы исключение в нулевом кадре, но получаем его только на следующем.

Стоит упомянуть, что у UniTask есть UniTaskVoid. Это облегчённая версия UniTask, и является альтернативой async void. Если вы не предполагаете использование ключевого слова await, сценарий «запустил и забыл», то лучше использовать UniTaskVoid.

public void StartOperation()
{
    FireAndForgetMethodAsync().Forget();
}

private async UniTaskVoid FireAndForgetMethodAsync()
{
    // Async operation.
}

И если async void метод контролируется стандартной системой задач C#, то управление UniTaskVoid полностью лежит на UniTask. Поэтому, если в предыдущем примере мы в методе SomeMethod() поменяем void на UniTaskVoid.

То в логе увидим следующее:

  • Frame: 0 Before exception.

  • Frame: 0 Exception of type 'System.Exception' was thrown.

  • Frame: 0 Method end.

Всё потому, что теперь метод SomeMethod() контролируется UniTask. И при возникновении исключения UniTaskVoid немедленно сообщает о нём в UniTaskScheduler.UnobservedTaskException. Но, несмотря на это, блок try/catch всё равно не поймает это исключение. Следовательно, даже используя UniTaskVoid, обработку исключений необходимо будет предусмотреть в самом методе.

UniTaskVoid может так же использоваться в методе Start():

public class AsyncSample : MonoBehaviour
{
    private async UniTaskVoid Start()
    {
        // Async initialization.
    }
}

3. Concurrency

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

Давайте представим, что у нас есть список элементов, который обновляется каждый раз, как пользователь, находясь вверху списка, тянет вниз «pull-to-refresh».

private void OnPullToRefresh()
{
    RefreshListItemsAsync(destroyCancellationToken).Forget();
}

private async UniTaskVoid RefreshListItemsAsync(CancellationToken cancellationToken)
{
    _list.RefreshData(await DownloadDataAsync(cancellationToken));
}

Как думаете, что произойдет, если нам попадётся нервный пользователь, который будет тянуть/обновлять список, пока не увидит новые данные? Правильно, метод RefreshListItemsAsync() запуститься ровно столько раз, сколько раз вызовется событие OnPullToRefresh(). Если пользователь, например, вызовет обновление списка три раза и потом просто будет наблюдать ничего не делая, он увидит, как данные обновятся три раза. Больше похоже на баг, чем на фичу.

Чтобы решить данную проблему, мы можем объявить переменную _isRefreshing, которая будет служить индикатором обновления списка и в зависимости от её значения, запускать или не запускать метод RefreshDataAsync().

private bool _isRefreshing;

private void OnPullToRefresh()
{
    if (_isRefreshing == false)
    {
        RefreshListItemsAsync(destroyCancellationToken).Forget();
    }
}

private async UniTaskVoid RefreshListItemsAsync(CancellationToken cancellationToken)
{
    _isRefreshing = true;
  
    try
    {
        _list.RefreshData(await DownloadDataAsync(cancellationToken));
    }
    finally
    {
        _isRefreshing = false;
    }
}

Решение с bool переменной хорошо подходит для сценариев «запустил и забыл». Но что, если нам необходимо, например, возвращать результат из метода, а запросов результата может быть несколько из разных мест программы?

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

public class NetworkService
{
    private readonly Client _client = new();

    public async UniTask<Data> LongServerRequestAsync(CancellationToken cancellationToken)
    {
        return await _client.LongServerRequestAsync(cancellationToken);
    }
}

Если воспользуемся подходом с bool переменной, получим примерно следующее:

public class NetworkService
{
    private readonly Client _client = new();

    private bool _isBusy;
    private Data _requestResult;

    public async UniTask<Data> LongServerRequestAsync(CancellationToken cancellationToken)
    {
        return _isBusy
            ? await WaitForResultAsync(cancellationToken)
            : await MakeRequestAsync(cancellationToken);
    }

    private async UniTask<Data> MakeRequestAsync(CancellationToken cancellationToken)
    {
        _isBusy = true;

        try
        {
            _requestResult = await _client.LongServerRequestAsync(cancellationToken);

            return _requestResult;
        }
        finally
        {
            _isBusy = false;
        }
    }

    private async UniTask<Data> WaitForResultAsync(CancellationToken cancellationToken)
    {
        while (_isBusy)
        {
            await UniTask.Yield(cancellationToken);
        }

        return _requestResult;
    }
}

Тут мы проверяем: если запрос на сервер уже отправлен, то ждём получения результата WaitForResultAsync(), иначе делаем запрос MakeRequestAsync(). Слишком много кода и логики получается для решения такой тривиальной задачи, вам не кажется?

К счастью, есть более элегантное решение. Мы можем объявить переменную _longServerRequestTask и сохранить задачу с запросом на сервер в неё. И когда кто-то вызовет метод LongServerRequestAsync(), мы проверим состояние этой задачи, и если она будет в статусе завершена, то сделаем новый запрос. В противном случае просто ждём завершения запроса, который уже выполняется.

public class NetworkService
{
    private readonly Client _client = new();

    private AsyncLazy<Data> _longServerRequestTask;

    public async UniTask<Data> LongServerRequestAsync(CancellationToken cancellationToken)
    {
        if (IsServerRequestCompleted())
        {
            _longServerRequestTask = _client
                .LongServerRequestAsync(cancellationToken)
                .ToAsyncLazy();
        }

        return await _longServerRequestTask.Task;
    }

    private bool IsServerRequestCompleted()
    {
        return _longServerRequestTask?.Task.Status.IsCompleted() ?? true;
    }
}

Обратите внимание, что поле _longServerRequestTask у нас типа AsyncLazy<Data>, а не UniTask<Data>. Всё потому, что UniTask не позволяет выполнить await задачи более одного раза, как и ValueTask. Связано это с тем, что UniTask – это переиспользуемая структура, и после await объект возвращается в пул. Повторный вызов await выбросит исключение, так как объект уже может использоваться в другом месте. Чтобы обойти это ограничение, мы декорируем наш UniTask<Data> классом AsyncLazy<Data>.

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

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

4. Asynchronous object creation

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

Это ограничение обычно обходится так:

public class ResourcesService
{
    private IResourcesProvider _resourcesProvider;

    public ResourcesService(IResourcesProviderFactory factory)
    {
        CreateResourcesProviderAsync(factory).Forget();
    }

    private async UniTaskVoid CreateResourcesProviderAsync(
        IResourcesProviderFactory factory)
    {
        _resourcesProvider = await factory.CreateResourcesProviderAsync();
    }

    public async UniTask<T> DownloadResourceAsync<T>(string resourcePath,
        CancellationToken cancellationToken)
    {
        while (_resourcesProvider is null)
        {
            await UniTask.Yield(cancellationToken);
        }

        return await _resourcesProvider.DownloadAsync<T>(resourcePath, cancellationToken);
    }
}

Здесь мы в конструкторе вызываем асинхронный метод CreateConnectionAsync() по принципу «запустил и забыл», а в цикле while метода DownloadResourceAsync() каждый раз проверяем, создался ли наш _resourcesProvider, и если нет, то ждём его создания. Подход, конечно, рабочий, но, к сожалению, выглядит как костыль.

Лучшим решением поставленной задачи будет использование паттерна «статический фабричный метод». Для этого сделаем конструктор класса ResourcesService приватным, а приватный метод CreateResourcesProviderAsync() заменим на публичный метод CreateAsync(), который будет возвращать созданный экземпляр класса.

public class ResourcesService
{
    private readonly IResourcesProvider _resourcesProvider;

    private ResourcesService(IResourcesProvider resourcesProvider)
    {
        _resourcesProvider = resourcesProvider;
    }

    public static async UniTask<ResourcesService> CreateAsync(
        IResourcesProviderFactory factory)
    {
        return new ResourcesService(await factory.CreateResourcesProviderAsync());
    }

    public async UniTask<T> DownloadResourceAsync<T>(string resourcePath,
        CancellationToken cancellationToken)
    {
        return await _resourcesProvider.DownloadAsync<T>(resourcePath, cancellationToken);
    }
}

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

5. WhenAny

Ещё одна распространённая ошибка связана с использованием метода WhenAny(). Допустим, нам надо показать рекламу и дождаться, пока пользователь нажмёт Escape или кнопку Skip, чтобы закрыть окно.

Обычно подобное реализуют следующим образом:

private async UniTask WaitForInputAsync(CancellationToken cancellationToken)
{
    var skipTask = _skipButton.WaitForClickAsync(cancellationToken);
    var escapeTask = _input.GetKeyUpAsync(KeyCode.Escape, cancellationToken);

    await UniTask.WhenAny(skipTask, escapeTask);
}

Выглядит отлично, правда? Метод WhenAny() завершится, когда любая из переданных в него задач завершится. Но есть нюанс, который многие упускают. И он заключается в том, что WhenAny() не завершает оставшиеся задачи. Т.е. если пользователь нажмёт кнопку Skip, метод WaitForInputAsync() отработает и задача skipTask будет завершена, но задача escapeTask останется запущенной и будет работать до тех пор, пока кнопкаEscape не будет нажата, ну или пока не сработает cancellationToken. И то же самое будет с задачей skipTask, если пользователь нажмёт Escape, а не кнопку Skip. Такие баги остаются практически незаметны, так как не проявляют себя, только разве что расходуют ресурсы CPU впустую.

Правильным решением тут будет использовать CreateLinkedTokenSource:

private async UniTask WaitForInputAsync(CancellationToken cancellationToken)
{
    using var linkedCts = CancellationTokenSource
                .CreateLinkedTokenSource(cancellationToken);

    var skipTask = _skipButton.WaitForClickAsync(linkedCts.Token);
    var escapeTask = _input.GetKeyUpAsync(KeyCode.Escape, linkedCts.Token);

    await UniTask.WhenAny(skipTask, escapeTask);

    linkedCts.Cancel();
}

Здесь мы создаём linkedCts и уже его токен передаём в наши методы, чтобы получить возможность завершить запущенные задачи позже. CreateLinkedTokenSource создает CancellationTokenSource, который автоматически вызовет метод Cancel(), если сработает переданный в него cancellationToken. Т.е. если сработает cancellationToken, то обе задачи skipTask и escapeTask прервутся, и мы покинем метод c OperationCanceledException на строке await UniTask.WhenAny(...). Если же у нас выполнится одна из задач, например, skipTask, наш метод WhenAny() завершится, и мы вручную прервём задачу escapeTask, вызвав linkedCts.Cancel().

OperationCanceledException

Обратите внимание, что если отмена произойдет по переданному в метод cancellationToken, то мы получим OperationCanceledException. Но когда мы вызываем linkedCts.Cancel() исключения нет. Всё потому, что в первом случае мы ещё следим за запущенными задачами в методе WhenAny(), а во втором случае уже никто не ожидает завершения оставшейся задачи. Следовательно, исключение возникнет, но будет скрыто.

Увидеть это исключение, можно так:

private async UniTask WaitForInputAsync(CancellationToken cancellationToken)
{
    using var linkedCts = CancellationTokenSource
                .CreateLinkedTokenSource(cancellationToken);

    var skipTask = _skipButton
        .WaitForClickAsync(linkedCts.Token)
        .ToAsyncLazy();

    var escapeTask = _input
        .GetKeyUpAsync(KeyCode.Escape, linkedCts.Token)
        .ToAsyncLazy();

    await UniTask.WhenAny(skipTask.Task, escapeTask.Task);

    linkedCts.Cancel();

    await skipTask.Task;
    await escapeTask.Task;
}

Такая логика может понадобиться, если мы хотим выполнить ещё что-то перед тем, как покинем метод WaitForInputAsync(), но нам важно дождаться завершения/прерывания всех запущенных задач. Важно заметить, что если мы хотим добавить дополнительную логику в конец метода, необходимо использовать метод расширения SuppressCancellationThrow(), чтобы подавить исключение OperationCanceledException, а не выйти из метода.

private async UniTask WaitForInputAsync(CancellationToken cancellationToken)
{
    ...

    await skipTask.Task.SuppressCancellationThrow();
    await escapeTask.Task.SuppressCancellationThrow();
    
    // Additional logic...
}

6. TaskCompletionSource

А вы знали, что любой метод можно превратить в асинхронный, даже если внешний API, который вы используете, не поддерживает асинхронность? Для этого можно использовать UniTaskCompletionSource, позволяющий создать UniTask, который может быть передан вызывающему коду. В свою очередь, состояние задачи, созданной с помощью UniTaskCompletionSource, контролируется явным образом методами в нём.

Допустим, у нас есть следующий класс:

public class ThirdPartyClass
{
    public event EventHandler Done;

    public void StartAction()
    {
        ...

        Done?.Invoke(this, EventArgs.Empty);
    }

    public void StopAction()
    {
        ...
    }
}

Как видим, в нём нет ни одного асинхронного метода, но есть событие Done, возникающие при завершении StartAction(). Используя это событие, мы можем сделать метод StartAction() асинхронным.

Давайте напишем расширение для нашего класса:

public static async UniTask StartActionAsync(this ThirdPartyClass thirdPartyClass,
    CancellationToken cancellationToken = default)
{
    var tcs = new UniTaskCompletionSource();

    void OnDone(object sender, EventArgs e)
    {
        thirdPartyClass.Done -= OnDone;
        tcs.TrySetResult();
    }

    thirdPartyClass.Done += OnDone;
    thirdPartyClass.StartAction();

    try
    {
        await tcs.Task.AttachExternalCancellation(cancellationToken);
    }
    catch (OperationCanceledException)
    {
        thirdPartyClass.StopAction();
        throw;
    }
}

Теперь мы можем легко встроить класс ThirdPartyClass в наш async/await пайплайн:

private async UniTask SomeMethodAsync(CancellationToken cancellationToken)
{
    ...

    await thirdPartyClass.StartActionAsync(cancellationToken);
    
    ...
}

Давайте теперь посмотрим на реализацию метода StartActionAsync() внимательнее. Видите там «event memory leak»? У нас есть подписка на событие Done, и есть отписка в методе OnDone(). Но что если операцию отменят? Верно, метод OnDone() не будет вызван и отписка не произойдет.

Поэтому лучше отписку перенести в блок finally:

public static async UniTask StartActionAsync(this ThirdPartyClass thirdPartyClass,
    CancellationToken cancellationToken = default)
{
    var tcs = new UniTaskCompletionSource();

    void OnDone(object sender, EventArgs e)
    {
        tcs.TrySetResult();
    }
    
    try
    {
        thirdPartyClass.Done += OnDone;
        thirdPartyClass.StartAction();

        await tcs.Task.AttachExternalCancellation(cancellationToken);
    }
    catch (OperationCanceledException)
    {
        thirdPartyClass.StopAction();
        throw;
    }
    finally
    {
        thirdPartyClass.Done -= OnDone;
    }
}

Теперь утечки памяти у нас нет и отписка будет произведена в любом случае.

7. Cancelling uncancellable operations

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

Представим, что у нас есть какая-то библиотека со следующим классом:

public class PurchaseManager
{
    ...

    public async Task InitializeAsync()
    {
        // Async initialization.
    }
}

Типичная библиотека от Unit'истов. И нам требуется реализовать инициализацию с возможностью отмены. Давайте воспользуемся методом расширения AttachExternalCancellation(), который UniTask предлагает из коробки.

Получим следующий код:

public class PurchaseService
{
    private readonly PurchaseManager _purchaseManager = new();
    
    public async UniTask InitializeAsync(CancellationToken cancellationToken)
    {
        await _purchaseManager
            .InitializeAsync()
            .AsUniTask()
            .AttachExternalCancellation(cancellationToken);
    }
}

Здесь мы прикрепляем cancellationToken к нашему асинхронному методу, который не поддерживает отмену, используя метод AttachExternalCancellation(). Этот код выглядит и в большинстве случаев даже работает так, как мы ожидаем. Но если быть точнее, то нам всего лишь кажется, что он работает так, как мы ожидаем. Давайте подробнее посмотрим на механизм работы AttachExternalCancellation().

Обычно AttachExternalCancellation() реализуется следующим образом:

public static async Task AttachExternalCancellation(this Task task,
    CancellationToken cancellationToken)
{
    var tcs =
        new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);

    await using (cancellationToken.Register(state =>
                 {
                     ((TaskCompletionSource<object>) state).TrySetResult(null);
                 }, tcs))
    {
        var resultTask = await Task.WhenAny(task, tcs.Task);

        if (resultTask == tcs.Task)
        {
            throw new OperationCanceledException(cancellationToken);
        }

        await task;
    }
}

В UniTask реализация немного другая, но суть такая же. Уже видите, в чём тут проблема? Отмена любой асинхронной операции, которая не поддерживает отмену из коробки – всего лишь формальность. И она как работала, так и продолжит работать, пока не завершится.

Мы можем попробовать реализовать что-то такое:

public class PurchaseService
{
    private readonly PurchaseManager _purchaseManager = new();

    private Task _purchaseManagerTask;

    public async UniTask InitializePurchaseManagerAsync(CancellationToken cancellationToken)
    {
        if (_purchaseManagerTask is null)
        {
            _purchaseManagerTask = _purchaseManager.InitializeAsync();
        }
        else if (_purchaseManagerTask.IsCompleted)
        {
            throw new Exception("Purchase manager is already initialized.");
        }

        await _purchaseManagerTask
            .AsUniTask()
            .AttachExternalCancellation(cancellationToken);
    }
}

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

И это плавно приводит нас к заключительному пункту.

8. CancellationToken

Ещё одной распространённой ошибкой является запуск асинхронной операции без возможности её отменить.

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

public class PopUpWindow : MonoBehaviour
{
    [SerializeField] private RawImage _rawImage;
    [SerializeField] private Button _closeButton;
    [SerializeField] private GameObject _loadingIndicator;

    public void Open(string imageUrl)
    {
        gameObject.SetActive(true);
        DownloadImageAsync(imageUrl).Forget();
    }

    public void Close()
    {
        gameObject.SetActive(false);
        Destroy(_rawImage.texture);
    }

    private async UniTaskVoid DownloadImageAsync(string imageUrl)
    {
        _loadingIndicator.SetActive(true);

        try
        {
            _rawImage.texture = await DownloadTextureAsync(imageUrl);
        }
        finally
        {
            _loadingIndicator.SetActive(false);
        }
    }

    private static async UniTask<Texture2D> DownloadTextureAsync(string uri)
    {
        using var www = UnityWebRequestTexture.GetTexture(uri);

        await www.SendWebRequest();

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

Как думаете, что произойдет, если закрыть окно до того, как изображение загрузилось? Верно, оно продолжит грузиться в фоне, расходуя ресурсы впустую. Но самое главное, если закрыть окно до того, как загрузится изображение, у нас вызовется Destroy(_rawImage.texture). Но изображения там ещё нет. Оно будет там только после завершения загрузки. И, как следствие, мы получаем утечку памяти.

Исправить это мы можем, просто реализовав поддержку CancellationToken:

public class PopUpWindow : MonoBehaviour
{
    ...

    private CancellationTokenSource _cancellationTokenSource;

    public void Close()
    {
        gameObject.SetActive(false);

        if (_cancellationTokenSource is null)
        {
            Destroy(_rawImage.texture);
        }
        else
        {
            _cancellationTokenSource.Cancel();
        }
    }

    private async UniTaskVoid DownloadImageAsync(string imageUrl)
    {
        _loadingIndicator.SetActive(true);

        _cancellationTokenSource = new CancellationTokenSource();

        try
        {
            var (isCanceled, texture) =
                await DownloadTextureAsync(imageUrl, _cancellationTokenSource.Token)
                    .SuppressCancellationThrow();

            if (isCanceled == false)
            {
                _rawImage.texture = texture;
            }
            else
            {
                print("Operation was canceled.");
            }
        }
        finally
        {
            _loadingIndicator.SetActive(false);

            _cancellationTokenSource.Dispose();
            _cancellationTokenSource = null;
        }
    }

    private static async UniTask<Texture2D> DownloadTextureAsync(string uri,
        CancellationToken cancellationToken = default)
    {
        using var www = UnityWebRequestTexture.GetTexture(uri);

        await www
            .SendWebRequest()
            .WithCancellation(cancellationToken);

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

Из интересного здесь – использование cancellationToken в методе DownloadTextureAsync(). UniTask из коробки предлагает расширение WithCancellation() для UnityWebRequestAsyncOperation. И это не то же самое, что AttachExternalCancellation() так как под капотом вызывается метод Abort() у нашего запроса. Так же обратите внимание на обработку операции отмены в методе DownloadImageAsync(). Здесь мы используем метод SuppressCancellationThrow(), который позволяет подавить исключение OperationCanceledException. Генерация исключений – операция не из лёгких, поэтому использование данного подхода весьма кстати, если производительность критична.

Unmanaged resources

По поводу утечки памяти – это весьма распространённая ошибка. Было у нас тестовое, где просто требовалось загружать случайный набор изображений из сети. Для нас было не важно, решат кандидаты его используя coroutine или async/await. Смотрели мы именно на работу с неуправляемыми ресурсами. Как думаете, сколько кандидатов в процентном соотношении допустили утечку памяти? За два года эта ошибка встречалась абсолютно во всех решениях, как бы грустно это не звучало.

Ещё стоит упомянуть, что CancellationTokenSource реализует интерфейс IDisposable, и если вы создаёте его вручную, то хорошо бы вызывать метод Dispose(). Конечно, если создавать CancellationTokenSource, как в примере выше, то ему нечего освобождать, и вызов метода Dispose() не является обязательным. Но если вы создаёте CancellationTokenSource с использованием таймера например, для отмены операции по тайм-ауту, то вызов Dispose() обязателен.

В этом примере Dispose() не вызывается, поэтому таймер остается активным на протяжении всего оставшегося времени:

public async UniTask<Stream> HttpClientWithCancellationBadAsync()
{
    var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));

    var response = await _httpClient.GetAsync("...", cts.Token);
  
    return await response.Content.ReadAsStreamAsync();
}

В этом примере Dispose() вызывается, поэтому таймер сразу удаляется:

public async UniTask<Stream> HttpClientWithCancellationGoodAsync()
{
    using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)))
    {
        var response = await _httpClient.GetAsync("...", cts.Token);
      
        return await response.Content.ReadAsStreamAsync();
    }
}

Чтобы не ломать голову над тем, когда вызывать Dispose(), а когда нет, просто вызывайте его всегда. Как поступаете со всеми классами, которые реализуют IDisposable.

В Unity, начиная с версии 2022.2, у MonoBehaviour класса появился destroyCancellationToken, который будет полезен, если вы хотите остановить выполнение асинхронного кода при уничтожении объекта. Если же вы используете более раннюю версию Unity, то UniTask предлагает метод расширения GetCancellationTokenOnDestroy() для MonoBehaviour класса. При необходимости отследить закрытие приложения, можно воспользоваться Application.exitCancellationToken.

Заключение

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

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

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

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


  1. Ka33yC
    19.09.2023 11:08

    Отличная статья, однако с "Async void" не согласен. Есть случаи когда без него не обойтись(подписка на ивенты). Но таких случаев в действительности мало, поэтому ПОЧТИ ВСЕГДА нужно возвращать Task вместо void.

    А про CreateLinkedTokenSource и вовсе не знал, спасибо!


    1. Dmitry9192 Автор
      19.09.2023 11:08
      +1

      В статье про это есть)

      В C# async void методы обычно используются для сценариев «запустил и забыл». В идеале эти методы не должны быть блокирующими и, как правило, используются только в обработчиках событий или асинхронном коде верхнего уровня.


      1. s207883
        19.09.2023 11:08

        Я бы еще добавил, что асинхронные методы имеет смысл именовать как DoSomethingAsync(), тогда, даже на уровне написания не запутаешься, даже если там void.


        1. Dmitry9192 Автор
          19.09.2023 11:08

          А как добавление суффикса Async к async void методу решает описанную проблему? Это только больше вводит в заблуждение. Почему метод асинхронный, а я не могу воспользоваться ключевым словом await? Это то же самое, что взять плохой код, но при этом назвать его красиво, например, «Photon».

          По поводу добавления суффикса Async к методам, которые возвращают UniTask, Task и т.д. согласен. Во всех приведённых примерах эта конвенция наименования соблюдена.

          P.S. Все названия и события вымышлены. Любые совпадения с реальными продуктами случайны.


  1. sttrox
    19.09.2023 11:08

    В более старых версиях юнити, к примеу 2021, метод GetCancellationTokenOnDestroy() вешает на GO свой компонент AsyncDestroyTrigger. Это хорошо бы иметь ввиду.


    1. Dmitry9192 Автор
      19.09.2023 11:08

      Всё верно. А ещё GetCancellationTokenOnDestroy() под капотом вызывает TryGetComponent(). Поэтому, если планируете его использовать, лучше кэшировать CancellationToken на старте.


    1. Tutanhomon
      19.09.2023 11:08

      В более старых версиях юнитаска, может? Потому что UniTask не является частью юнити.
      По теме - лучше сразу повесить AsyncDestroyTrigger на компонент, потому что TryGetComponent это еще ничего, а вот AddComponent уже побольнее будет


  1. Igor_Sib
    19.09.2023 11:08

    Статья отличная, как и первая часть.

    Почему решили использовать UniTask вместо ValueTask?

    Если интересует мое мнение: мне сначала нравились UniTask, но не удобно то что они не показывают стек вызова, в эксепшене ты видишь только что вызов был из PlayerLoop и юнитаску. ValueTask честно показывает стек вызова, откуда была запущена.


    1. Dmitry9192 Автор
      19.09.2023 11:08

      Если коротко, то UniTask был написан специально для Unity, тесно интегрирован с игровым циклом и учитывает его особенности. Т.е. например, ситуации, когда запустил задачу, а потом вышел из Play Mode, а задача продолжает работать, не будет.

      По поводу стека вызовов. Не совсем понял.

      Следующий код:

      public class AwaitAllTheWayTest : MonoBehaviour
      {
          [ContextMenu(nameof(StartOperation))]
          public void StartOperation()
          {
              FirstAsync(destroyCancellationToken).Forget();
          }
      
          private async UniTaskVoid FirstAsync(CancellationToken cancellationToken)
          {
              await SecondAsync(cancellationToken);
          }
      
          private async UniTask SecondAsync(CancellationToken cancellationToken)
          {
              await ThirdAsync(cancellationToken);
          }
      
          private async UniTask ThirdAsync(CancellationToken cancellationToken)
          {
              await UniTask.Delay(1000, cancellationToken: cancellationToken);
              throw new Exception();
          }
      }

      Выдаст такой стек вызовов:

      Exception: Exception of type 'System.Exception' was thrown.
      
      AwaitAllTheWay.AwaitAllTheWayTest.ThirdAsync (at Assets/Scripts/AwaitAllTheWay/AwaitAllTheWayTest.cs:29)
      ...
      AwaitAllTheWay.AwaitAllTheWayTest.SecondAsync (at Assets/Scripts/AwaitAllTheWay/AwaitAllTheWayTest.cs:23)
      ...
      AwaitAllTheWay.AwaitAllTheWayTest.FirstAsync (at Assets/Scripts/AwaitAllTheWay/AwaitAllTheWayTest.cs:18)
      ...
      AwaitAllTheWay.<FirstAsync>d__1:MoveNext() (at Assets/Scripts/AwaitAllTheWay/AwaitAllTheWayTest.cs:18)
      ..
      AwaitAllTheWay.<SecondAsync>d__2:MoveNext() (at Assets/Scripts/AwaitAllTheWay/AwaitAllTheWayTest.cs:23)
      ...
      AwaitAllTheWay.<ThirdAsync>d__3:MoveNext() (at Assets/Scripts/AwaitAllTheWay/AwaitAllTheWayTest.cs:29)
      ...


      1. Igor_Sib
        19.09.2023 11:08

        Хм, возможно я не прав и что-то путаю.