Коварный AttachExternalCancellation

Коварный AttachExternalCancellation

Если вы работаете с асинхронностью в Unity, то наверняка знакомы с UniTask. Это более эффективный и удобный для Unity инструмент, чем обычные шарповые Task. Его удобство часто достигается за счёт разнообразного "сахара". Одна из таких "сладостей" — это метод AttachExternalCancellation, который очень часто используют не по назначению.

В чём проблема

Обычно для прерывания асинхронной операции используют CancellationToken: его передают внутрь и отслеживают.

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

📎 Подробнее про это и полезные советы можно почитать здесь.

Соответственно, если это какой-то метод UniTask, например Delay, то вся логика остановки внутри уже реализована — достаточно только передать токен.

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

_cts = new CancellationTokenSource(); // просто пример использования, смысла 0 await ExecuteAsync() .AttachExternalCancellation(_cts.Token); _cts.Dispose();

UniTask и Task — это не сопрограммы, которые привязаны к циклу конкретного MonoBehaviour. Соответственно, они не смогут остановиться магическим образом, если к ним просто "сбоку" приставить токен.

AttachExternalCancellation не останавливает выполнение кода внутри задачи. Он лишь останавливает ожидание этой задачи.

Т.е. если вы делали await какого-то метода — он прекратится, но сам асинхронный метод продолжит работать.

Похожим образом работает и метод ToUniTask(CancellationToken token). Ему тоже не стоит доверять.

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

Последствия

  • Непредсказуемое поведение: вы можете не ожидать, что якобы остановленный метод продолжит работать и как-то взаимодействовать с другими объектами, особенно от Unity, которые могут стать уничтоженными.
  • Лишняя нагрузка: метод продолжает работать и отъедать аппаратные ресурсы.
  • Утечки памяти: работающая задача держит ссылки на объекты и не отдаёт их сборщику мусора (об этом уже упоминалось в этом посте 💬)

Когда это нужно

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

Например, загрузка Addressables. Но тут важно не просто прервать ожидание: нужно ещё в фоне дождаться завершения загрузки и выполнить затем Release. Иначе этот ассет останется невыгруженным.

// один из десятка способов это сделать async UniTask<Sprite> LoadIconAsync(string key, CancellationToken token) { var handle = Addressables.LoadAssetAsync<Sprite>(key); try { return await handle.ToUniTask() .AttachExternalCancellation(token); } catch (OperationCanceledException) { _ = handle.ToUniTask() .ContinueWith(_ => Addressables.Release(handle)); throw; } }

Т.е. некоторые такие "неотменяемые" операции обязательно нужно дожидаться и финализировать.

Дополнительные рекомендации

  • Всегда передавайте CancellationToken в каждый асинхронный метод, в т.ч. вложенные.
  • Строго контролируйте жизненный цикл каждого такого метода и его CancellationTokenSource.
  • Не забывайте вызывать и Cancel, и Dispose для финализации CancellationTokenSource.
  • В MonoBehaviour можно использовать this.GetCancellationTokenOnDestroy(), привязанный к конкретному MonoBehaviour, по аналогии с Coroutine.
1
Начать дискуссию