Unity 2022.2 continues to integrate async await

Unity 2022.2 has taken another small step towards async-await support, announced back in May 2022 in an article https://blog.unity.com/technology/unity-and-net-whats-next. The destroyCancellationToken property has been added to UnityEngine.MonoBehaviour, which allows you to stop the task at the moment the object is destroyed. Added a property to UnityEngine.Application with an exitCancellationToken that is canceled when exiting Play Mode. Let’s briefly recall the difference between Coroutine and async-await and apply the new properties.

Coroutine example

Coroutines are basically simple IEnumerator methods that are iterated through the Player Loop always on the main thread. From Coroutine you can return null or 4 object types: WaitForSeconds, WaitForFixedUpdate, WWW or some other Coroutine. Depending on the type, you can accurately predict when a return to a method will occur. This can be seen in the diagram. https://docs.unity3d.com/Manual/ExecutionOrder.html. If the returned object does not belong to any of the listed ones, then it will be treated as null.

Each Coroutine is strictly tied to the MonoBehaviour it is called on. So for example, if the game object is turned off or the MonoBehaviour itself is turned off, then the Coroutine call will not occur. When an object is destroyed, Coroutine is no longer processed at all.

This provides convenience, but also introduces its limitations. For example, you can no longer control the constant turning on and off of an object through the Coroutine of the object itself. Only through some external object.

I will give an example with flashing objects on the stage. Let’s create such a component.

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);
        }
    }
}

Executing this component will only disable the object, and it will never be enabled again. Because returning to Coroutine on a disabled object will not occur. The classic solution to this situation is to control the flashing object from the outside.

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);
        }
    }
}

Here, one object will control the other, which was specified in target. Again, if you specify yourself for it, then the script will not work. It’s good practice to check.

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.");
}

In most other cases, this relationship between Coroutine and MonoBehaviour is convenient. Any movement control of an object via Coroutine will be stopped as soon as the object is turned off or destroyed.

An example of using async await to create a blinking object

Which thread control will be transferred to after await depends on the SynchronizationContext used. In Unity, await always returns control to the main thread, which is what is required in most cases.

Let’s write a component to control the blinking of an object via 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);
        }
    }
}

After each use of Task.Delay, you have to check to see if the object has been destroyed.

if (this == null)
{
    break;
}

In such a situation, it is better to use CancellationToken to cancel the execution of the Task as soon as the object has been destroyed. The following example will use the MonoBehaviour.destroyCancellationToken property, which was added in 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);
        }
    }
}

However, canceling the Task throws a TaskCanceledException, which is what we get if we just destroy the object at run time. Async-void methods should be avoided. If the method does not have a useful result, then it should return a Task. If, nevertheless, it is not possible to make such a method, as in the example with void Start(), the execution should be wrapped in a 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);
        }
    }
}

With this approach, there is no need to worry about whether the object will be enabled or disabled, control will return to the method in any case, and execution will stop when it is destroyed. At the same time, the code turned out to be quite compact. For example, in the past, the processing of the destroyCancellationToken would have to be done manually.

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()
    {
        ...
    }
}

Using async-await in isolation from MonoBehaviour

When working with Unity, we may come across many other libraries and packages that can use the async-await approach. Or when we create an asynchronous task in isolation from game objects, for example, to implement some other game logic. Starting with Unity 2022.2, it will be possible to use UnityEngine.Application.exitCancellationToken to stop them in a timely manner.

I will give a hypothetical example of the early initialization of the game.

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);
    }
}

Prior to the introduction of Application.exitCancellationToken, this token had to be handled manually. Let me remind you that if you just run an async Task, without cancel processing, then this task will continue to run even after the game stops when you switch to Edit Mode.

Comparing Coroutine and async-await

Waiting for a Task via await often allocates objects in memory, which can cause performance issues if used frequently. So, when managing game objects in Unity, despite all the conveniences of the async-await syntax, in my opinion, it is still preferable to use Coroutine.

One of the biggest shortcomings of Coroutine is the inability to return a result. In such a situation, you can already turn to the standard approach in .NET using async-await.

You can also create your own analogue of Task, more adapted to Unity and more productive, which will support the in-game time Time.time, Time.deltaTime, etc. But this is a big topic for a separate article. At the moment, there is already a UniTask library that combines all the conveniences of async-await and adaptability for Unity.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *