Как стать автором
Обновить
761.31
OTUS
Цифровые навыки от ведущих экспертов

Async/await в Unity

Время на прочтение7 мин
Количество просмотров44K
Автор оригинала: 5argon / Sirawat Pitaksarit
Салют, хабровчане. Совсем немного времени остается до старта курса «Разработчик игр на Unity», в связи с этим мы подготовили для вас еще один интересный перевод.




async в Unity уже и так работает без каких-либо плагинов или оборачивающих Task корутин, имитирующих асинхронное поведение проверяя завершение на каждом кадре. Но это все-равно своего рода магия. Давайте же немного углубимся в эту тему.

(Примечание: пока еще я не обладаю идеальным пониманием всех подкапотных тонкостей async/await в C#, по этому я буду стараться дополнять изложенное, по мере углубления моего понимания.)

Пример




Допустим, мне нужна кнопка, при нажатии на которую воспроизводилась бы классическая анимация коробки. Но есть одна загвоздка: кнопка должна переходить в неактивное состояние (затемняться посредством .interactable) до тех пор, пока коробка не закончит вращение.

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

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

using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;

public class SpinBox : MonoBehaviour
{
    public Button button;
    public Animation ani;

    public async void SpinAndDisableButton()
    {
        ani.Play();
        button.interactable = false;
        while (ani.isPlaying == true)
        {
            await Task.Yield();
        }
        button.interactable = true;
    }
}



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

  • «Кто» «исполняет» while и оставшуюся часть метода? Каким магическим образом он проверяется в следующем кадре, тогда как вызов SpinAndDisableButton выполняется только один раз, и нет никаких Update или корутин для повторного запуска.
  • Каков тайминг каждого запуска?
  • Что такое Task.Yield()? Кажется, это ключ ко всему, что здесь происходит. Я полагаю, вы привыкли к yield return null в корутинах. Хорошей догадкой было бы сказать — попробуйте снова в следующем кадре, но вы говорите это через энумератор C#. Формулировка «yield» схожа, и даже поведение аналогично.

Контекст синхронизации


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

Тогда вы заметите, что в Unity тоже есть такая концепция, но она не видна нам на первый взгляд.

В сравнении с обычной программой на C#


Все не совсем так, как в обычных программах на C#. Взглянем на официальную документацию await. await подразумевает возврат вызывающей стороне объекта Task. Вызывающий объект может продолжать до тех пор, пока ему не понадобится результат выполнения, и он больше не может позволить себе делать в это время что-то еще (не имеет значения, является ли задача многопоточной или нет), кроме как await. Эта цепочка может продолжаться до тех пор, пока вы, наконец, не доберетесь до Main. Где, если мы также встречаем async, есть еще один await генерируемый компилятором, который немедленно запрашивает результат.

Теперь вернемся к нашей SpinAndDisableButton. Куда идет возврат await? В Unity у нас нет Main, так как он глубоко скрыт в коде движка. Вопрос, по сути, такой же, как и кто исполняет Update, LateUpdate и так далее. Это PlayerLoop API, которое поддерживает код движка в рамках игрового цикла, чтобы обеспечить упорядоченный рендеринг объектов и всего, что исполняется в кадре. Но раньше нас это не волновало, поскольку до сих пор эти точки входа возвращали void.

Теперь await возвращает точку выполнения кому-то в надежде, что он сможет продолжить с этой же точки позже, в определенный момент, автоматически. Затем игровой цикл продолжается, пока await находится в ожидании. В противном случае мы бы вообще не увидели вращающуюся коробку, если бы мы действительно ожидали, пока коробка не закончит анимацию, потому что анимация не может закончиться, если смена кадров не продолжается. Так что же произойдет в следующем кадре?

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

for (игровой цикл)
{
    if(анимация коробки закончена)
    {
    	Включаем кнопку.
    }
  
    Анимация продолжается.
    Отправить матрицу преобразования коробки на рендеринг.
    Мы видим обновленный рендеринг коробки.
}

Отладка


Включите в код побольше логов, и мы попробуем пошаговую отладку.
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;

public class SpinBox : MonoBehaviour
{
    public Button button;
    public Animation ani;

    public async void SpinAndDisableButton()
    {
        Debug.Log($"Started async {Time.frameCount}");
        ani.Play();
        button.interactable = false;
        while (ani.isPlaying == true)
        {
            await Task.Yield();
        }
        button.interactable = true;
        Debug.Log($"Finished async {Time.frameCount}");
    }

    public void Update()
    {
        Debug.Log($"Update {Time.frameCount}");
    }
    
    public void LateUpdate()
    {
        Debug.Log($"Late Update {Time.frameCount}");
    }
}

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





  • Кадр, в котором мы запускаем анимацию, предшествует Update и LateUpdate, потому что клик мыши происходит благодаря EventSystem и GraphicRaycaster из UGUI Unity, который, как оказалось, имеет порядок выполнения в Update выше любого из ваших скриптов.
  • while ожидание возникает магическим образом в каждом кадре с этого места, по таймингу после Update, но до LateUpdate. Как будто мы породили корутину, но мы этого не сделали!
  • Еще один интересный момент заключается в том, что в первом кадре, где мы только начали воспроизводить анимацию, присутствуют два Awaiting, поскольку система событий UGUI появилась еще раньше, намекая на то, что await подписка, немедленно вступает в силу без необходимости резюмировать что-либо в следующем кадре
  • В старые времена мы должны были поставить проверку в Update или что-то в этом роде. await подписка устраняет необходимость в этом.

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

В первом кадре, где я нажал на кнопку, нет ничего странного. Update (тот же магический MonoBehaviour Update) EventSystem использует Input API и видит мой клик, после чего сканирует мою кнопку на канвасе и видит, что он может что-то сделать. Затем он вызывается в public async void и достигает этой строки. (Вы также можете использовать эти ExecuteEvents вручную! Это вспомогательный static класс.)



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



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



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

Task.Yield()


Этот метод имеет очень запутанное описание:
docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.yield?view=netframework-4.8

Возвращает
YieldAwaitable

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

Замечания

Вы можете использовать await Task.Yield(); в асинхронном методе, чтобы заставить метод завершиться асинхронно. Если существует текущий контекст синхронизации (объект SynchronizationContext), он отравит остаток выполнения метода обратно в этот контекст. Тем не менее, контекст решит, как расставить приоритеты для этой работы относительно другой работы, которая может быть ожидающей. Контекст синхронизации, который присутствует в UI потоке в большинстве сред UI, часто отдает приоритет работе, размещенной в контексте выше, чем работа ввода и рендеринга. По этой причине не полагайтесь, что await Task.Yield(); будет сохранять UI отзывчивым. Для получения дополнительной информации смотрите «Полезные абстракции, реализованные с помощью ContinueWith» в блоге «Параллельное программирование с .NET».


Английский не мой родной язык, и я думаю, что ни C# yield return, ни Task.Yield() не отражают функцию, которую они выполняют. Но давайте перейдем к определению метода.



Предполагаемый вариант использования в примечании, кажется, говорит о том, что метод на самом деле не async, но вы хотели бы превратить его в async, поэтому вам нужно await (ожидать) где-то в нем, поэтому Task.Yield() — идеальный козел отпущения. Вместо того, чтобы сразу завершать метод (помните, что async не будет волшебным образом превращать метод в асинхронный, это зависит от содержимого метода), теперь вы заставляете его быть асинхронным.

Но в нашем случае получатель контекста — это не обычный контекст, а UnitySynchronizationContext. Теперь Task.Yield() имеет более полезную функцию, которая эффективно продолжает работу в следующем кадре. Если бы это был обычный SynchronizationContext, я предполагаю это могло бы произвести к бесконечному циклу в нашем примере, так как он будет выполнять continue прямиком в while снова и снова.

Он возвращает YieldAwaiter, который вызывающий объект может использовать для продолжения. Как свидетельствует наш лог, «Awaited» регистрировался в начале каждого кадра, потому что контекст (код рядом с await) был сохранен в этом авейтере. UnitySynchronizationContext делает какую-то магию, ждет кадр и использует этот авейтер для продолжения, затем он достигает while и снова возвращает новый YieldAwaiter. Это, вероятно, продолжит добавление нового WorkRequest для кода ранее в каждом кадре в качестве ожидающей задачи, пока while не станет false.

UniTask


Существует популярный пакет UniTask, который полностью исключает SynchronizationContext и вводит новый тип более легкой задачи UniTask, которая напрямую привязывается к PlayerLoop API. Затем вы можете выбрать время, когда должна произойти проверка в следующем кадре. (Инициализация? Late update?) Но, по сути, вы знаете, что await работает без какого-либо плагина. Это будет полезно с Addressables, где AsyncOperationHandle может быть .Task, который вы можете использовать с await.

Для просмотра



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





Создаем сетевой шутер в космосе за полтора часа.


Теги:
Хабы:
+5
Комментарии6

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS