Коварный AttachExternalCancellation
Если вы работаете с асинхронностью в Unity, то наверняка знакомы с UniTask. Это более эффективный и удобный для Unity инструмент, чем обычные шарповые Task. Его удобство часто достигается за счёт разнообразного "сахара". Одна из таких "сладостей" — это метод AttachExternalCancellation, который очень часто используют не по назначению.
В чём проблема
Обычно для прерывания асинхронной операции используют CancellationToken: его передают внутрь и отслеживают.
Если во время исполнения был получен запрос на отмену, то нужно выбросить соответствующее исключение или прервать исполнение метода более мягко.
📎 Подробнее про это и полезные советы можно почитать здесь.
Соответственно, если это какой-то метод UniTask, например Delay, то вся логика остановки внутри уже реализована — достаточно только передать токен.
Почему-то принято считать, что AttachExternalCancellation работает таким же образом, хотя его можно приставить к любому асинхронному методу, который даже изначально и не подразумевал передачу ему токена.
UniTask и Task — это не сопрограммы, которые привязаны к циклу конкретного MonoBehaviour. Соответственно, они не смогут остановиться магическим образом, если к ним просто "сбоку" приставить токен.
❗ AttachExternalCancellation не останавливает выполнение кода внутри задачи. Он лишь останавливает ожидание этой задачи.
Т.е. если вы делали await какого-то метода — он прекратится, но сам асинхронный метод продолжит работать.
Похожим образом работает и метод ToUniTask(CancellationToken token). Ему тоже не стоит доверять.
Этот кейс, и некоторые другие, более подробно рассмотрены в этой статье на Хабр из числа моих любимых.
Последствия
- Непредсказуемое поведение: вы можете не ожидать, что якобы остановленный метод продолжит работать и как-то взаимодействовать с другими объектами, особенно от Unity, которые могут стать уничтоженными.
- Лишняя нагрузка: метод продолжает работать и отъедать аппаратные ресурсы.
- Утечки памяти: работающая задача держит ссылки на объекты и не отдаёт их сборщику мусора (об этом уже упоминалось в этом посте 💬)
Когда это нужно
Только тогда, когда есть чужое внешнее API, которое не принимает токен отмены, но вам необходимо прервать ожидание.
Например, загрузка Addressables. Но тут важно не просто прервать ожидание: нужно ещё в фоне дождаться завершения загрузки и выполнить затем Release. Иначе этот ассет останется невыгруженным.
Т.е. некоторые такие "неотменяемые" операции обязательно нужно дожидаться и финализировать.
Дополнительные рекомендации
- Всегда передавайте CancellationToken в каждый асинхронный метод, в т.ч. вложенные.
- Строго контролируйте жизненный цикл каждого такого метода и его CancellationTokenSource.
- Не забывайте вызывать и Cancel, и Dispose для финализации CancellationTokenSource.
- В MonoBehaviour можно использовать this.GetCancellationTokenOnDestroy(), привязанный к конкретному MonoBehaviour, по аналогии с Coroutine.