Коварный 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.
