В Unity 2022.2 был сделан ещё один небольшой шаг в сторону поддержки async-await, анонсированный еще в мае 2022 года в статье "Unity and .NET, what’s next?". В UnityEngine.MonoBehaviour было добавлено свойство destroyCancellationToken, которое позволяет остановить задачу в момент уничтожения объекта. В UnityEngine.Application добавлено свойство с токеном exitCancellationToken, который отменяется в момент выхода из Play Mode. Коротко вспомним отличие Coroutine от async-await и применим новые свойства.

Пример использования Coroutine

Coroutine, по сути, это простые IEnumerator-методы, которые итерируются в Player Loop всегда в главном потоке. Из Coroutine вы можете вернуть null или 4 типа объекта: WaitForSeconds, WaitForFixedUpdate, WWW или же какой-то другой Coroutine. В зависимости от типа можно точно предсказать, когда произойдет возврат в метод. Это можно посмотреть на схеме. Если возвращаемый объект не будет относиться ни к одному из перечисленных, то он будет воспринят как null.

Каждый Coroutine строго привязан к MonoBehaviour, которым он вызывается. Так к примеру, если game object будет выключен или будет отключен сам MonoBehaviour, то и вызов Coroutine не будет происходить. При уничтожении объекта, Coroutine вовсе перестает как либо обрабатываться.

Это дает удобство, но и вносит свои ограничения. К примеру, вы уже не можете управлять постоянным включением и выключением объекта через Coroutine самого объекта. Только через какой-то внешний объект.

Приведу пример с мигающими объектами на сцене. Создадим такой компонент.

public class BlinkingObject : MonoBehaviour
{
    public float period;

    public IEnumerator Start()
    {
        var delay = new WaitForSeconds(period);
        
        while (true)
        {
            yield return delay;
            gameObject.SetActive(false);
            yield return delay;
            gameObject.SetActive(true);
        }
    }
}

Выполнение этого компонента приведёт только к отключению объекта, и он никогда не включится вновь. Т.к. возврат в Coroutine на выключенном объекте не произойдёт. Классическое решение данной ситуации — это управление мигающим объектом извне.

public class BlinkingObject : MonoBehaviour
{
    public float period = 1;
    public GameObject target;

    public IEnumerator Start()
    {
        var delay = new WaitForSeconds(period);
        
        while (true)
        {
            yield return delay;
            target.SetActive(false);
            yield return delay;
            target.SetActive(true);
        }
    }
}

Тут один объект будет управлять другим, который был указан в target. Опять же, если указать для него самого себя, то скрипт работать не будет. Хорошим тоном будет сделать проверку.

if (target == gameObject)
{
    throw new Exception(
        $"Specified {nameof(GameObject)} in the variable {nameof(target)} of {nameof(BlinkingObject)} " +
        $"on the '{gameObject.name}' {nameof(GameObject)} is the same as the parent one.");
}

В большинстве других случаев подобная взаимосвязь Coroutine и MonoBehaviour удобна. Любое управление движением объекта через Coroutine будет остановлено, как только объект будет выключен или уничтожен.

Пример использование async await для создания мигающего объекта

В какой поток будет передано управление после await зависит от используемого SynchronizationContext. В Unity await всегда возвращает управление в основной поток, что и требуется в большинстве случаев.

Напишем компонент для управления миганием объекта через async-await.

public class BlinkingObject : MonoBehaviour
{
    public int period = 1;

    public async void Start()
    {
        // Converts seconds to milliseconds.
        var delay = period * 1000; 
        
        while (true)
        {
            await Task.Delay(delay);
            
            if (this == null)
            {
                break;
            }
            
            gameObject.SetActive(false);
            await Task.Delay(delay);
            
            if (this == null)
            {
                break;
            }
            
            gameObject.SetActive(true);
        }
    }
}

После каждого использования Task.Delay приходится делать проверку на то, был ли уничтожен объект.

if (this == null)
{
    break;
}

В такой ситуации лучше использовать CancellationToken, чтобы отменять выполнение Task, сразу как только объект был уничтожен. Следующий пример будет использовать свойство MonoBehaviour.destroyCancellationToken, которое было добавлено в Unity 2022.2.

public class BlinkingObject : MonoBehaviour
{
    public int period = 1;

    public async void Start()
    {
        // Converts seconds to milliseconds.
        var delay = period * 1000; 
        
        while (!destroyCancellationToken.IsCancellationRequested)
        {
            await Task.Delay(delay, destroyCancellationToken);
            gameObject.SetActive(false);
            await Task.Delay(delay, destroyCancellationToken);
            gameObject.SetActive(true);
        }
    }
}

Однако, отмена Task вызывает TaskCanceledException, который мы и получим, если просто уничтожим объект в момент выполнения. Следует избегать async-void методов. Если в методе нет полезного результата, то он должен возвращать Task. Если, всё-таки нет возможности сделать такой метод, как в примере с void Start(), выполнение следует оборачивать в try-catch.

public class BlinkingObject : MonoBehaviour
{
    public int period = 1;

    public async void Start()
    {
        try
        {
            await BlinkAsync();
        }
        catch (TaskCanceledException) { }
    }

    private async Task BlinkAsync()
    {
        // Converts seconds to milliseconds.
        var delay = period * 1000; 
        
        while (!destroyCancellationToken.IsCancellationRequested)
        {
            await Task.Delay(delay, destroyCancellationToken);
            gameObject.SetActive(false);
            await Task.Delay(delay, destroyCancellationToken);
            gameObject.SetActive(true);
        }
    }
}

При таком подходе нет нужды беспокоиться будет ли объект включен или выключен, управление будет возвращаться в метод в любом случае, а при уничтожении выполнение остановится. При этом код получился достаточно компактным. К примеру, раньше обработку токена destroyCancellationToken приходилось бы делать вручную.

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

public class BlinkingObject : MonoBehaviour
{
    ...

    private CancellationTokenSource _cancellationTokenSource;

    private CancellationToken DestroyCancellationToken => _cancellationTokenSource.Token;

    private async void Start()
    {
        _cancellationTokenSource = new CancellationTokenSource();
        
        ...        
    }

    private void OnDestroy()
    {
        _cancellationTokenSource.Cancel();
        _cancellationTokenSource.Dispose();
    }

    private async Task BlinkAsync()
    {
        ...
    }
}

Использование async-await в отрыве от MonoBehaviour

При работе с Unity мы можем сталкиваться со множеством других библиотек и пакетов, которые могут использовать async-await подход. Или когда мы создаем асинхронную задачу в отрыве от игровых объектов, например, для реализации какой-от другой игровой логики. Начиная с Unity 2022.2, можно будет использовать UnityEngine.Application.exitCancellationToken для их своевременной остановки.

Приведу гипотетический пример ранней инициализации игры.

public static class Boot
{
    [RuntimeInitializeOnLoadMethod]
    public static async void Initialization()
    {
        try
        {
            await StartGameAsync(Application.exitCancellationToken);
        }
        catch (OperationCanceledException) { }
    }

    private static async Task StartGameAsync(CancellationToken cancellationToken)
    {
        await SomeJobBeforeInitialization(cancellationToken);
        await Addressables.InitializeAsync().Task;
        await PreloadingSomeBundles(cancellationToken);
        await Addressables.LoadSceneAsync("StartMenuScene").Task;
    }

    private static async Task SomeJobBeforeInitialization(CancellationToken cancellationToken)
    {
        await Task.Delay(1000, cancellationToken);
    }

    private static async Task PreloadingSomeBundles(CancellationToken cancellationToken)
    {
        await Task.Delay(1000, cancellationToken);
    }
}

До введения Application.exitCancellationToken приходилось делать обработку этого токена вручную. Напомню, что если просто так запустить async Task, без обработки отмены, то эта задача продолжит выполняться и после остановки игры при переходе в Edit Mode.

Сравнивая Coroutine и async-await

Ожидание Task через await  часто выделяют объекты в памяти, что может вызвать проблемы с производительностью, при частом использовании. Так при управлении игровыми объектам в Unity, несмотря на все удобства синтаксиса async-await, на мой взгляд, всё ещё предпочтительнее использовать Coroutine.

Одним из самых больших недостатков Coroutine — это невозможность вернуть результат. В такой ситуации уже можно обратиться к стандартному подходу в .NET с использование async-await.

Также можно создать свой собственный аналог Task более адаптированный для Unity и более производительный, который будет поддерживать внутреигровое время Time.time, Time.deltaTime и т.п. Но это — большая тема для отдельной статьи. На данный момент уже существует библиотека UniTask, которая совмещает в себе все удобства async-await и адаптированность под Unity.

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


  1. Magistr_AVSH
    12.12.2022 01:07
    +2

    В 2023.1 же добавили Awaitable, встроенный Unitask, который решает проблему описанную в конце.


    1. denis_kondratev Автор
      12.12.2022 01:12

      Отличная новость :) сам ещё не добрался до 2023.1


  1. bustedbunny
    13.12.2022 10:22

    Есть же UniTask без GC аллокаций. Зачем в 2022 использовать корутины?


    1. CunningFox146
      13.12.2022 17:25

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