Unity 2023.1 Introduces Awaitable Class

Written by deniskondratev | Published 2023/01/27
Tech Story Tags: unity | asyncawait | awaitable | game-development | asynchronous | c-sharp | hackernoon-top-story | gaming | hackernoon-es | hackernoon-hi | hackernoon-zh | hackernoon-vi | hackernoon-fr | hackernoon-pt | hackernoon-ja | hackernoon-tr | hackernoon-ko | hackernoon-de | hackernoon-bn

TLDRThe article discusses the new Awaitable class introduced in Unity 2023.1, which allows for more opportunities for writing asynchronous code in Unity game development. It covers waiting methods, properties to stop asynchronous execution, and usage with other Unity tools such as Coroutines and InvokeRepeating. It emphasizes the importance of understanding the basics of async-await and experimenting with the Awaitable class to understand its capabilities and limitations.via the TL;DR App

In May 2022, Alexandre Mutel and Kristyna Hougaard announced in their post "Unity and .NET, what's next?" that Unity plans to adopt more of .NET's features, including the convenience of using async-await. And, it seems that Unity is following through on its promise. In the Unity 2023.1 alpha version, the Awaitable class has been introduced, providing more opportunities for writing asynchronous code.

Waiting Methods

In this section, I will not delve too deeply into methods that, in my opinion, have sufficient description in the official Unity documentation. They are all related to asynchronous waiting.

Awaitable.WaitForSecondsAsync() allows you to wait for a specified amount of game time. Unlike Task.Delay(), which performs a wait in real-time. To help clarify the difference, I will provide a small example later in a code block.

private void Start()
{
    Time.timeScale = 0;

    StartCoroutine(RunGameplay());
    
    Task.WhenAll(
        WaitWithWaitForSecondsAsync(),
        WaitWithTaskDelay());
}

private IEnumerator RunGameplay()
{
    yield return new WaitForSecondsRealtime(5);
    Time.timeScale = 1;
}

private async Task WaitWithWaitForSecondsAsync()
{
    await Awaitable.WaitForSecondsAsync(1);
    Debug.Log("Waiting WithWaitForSecondsAsync() ended.");
}

private async Task WaitWithTaskDelay()
{
    await Task.Delay(1);
    Debug.Log("Waiting WaitWithTaskDelay() ended.");
}

In this example, at the beginning of the Start() method, the game time is stopped using Time.timeScale. For the sake of the experiment, a Coroutine will be used to resume its flow after 5 seconds in the RunGameplay() method. Then, we launch two one-second waiting methods. One using Awaitable.WaitForSecondsAsync(), and the other using Task.Delay(). After one second, we will receive a message in the console "Waiting WaitWithTaskDelay() ended". And after 5 seconds, the message "Waiting WaitWithTaskDelay() ended" will appear.

Other convenient methods have also been added to give you more flexibility in Unity's basic Player Loop. Their purpose is clear from the name and corresponds to their analogy when using Coroutines:

  • EndOfFrameAsync()
  • FixedUpdateAsync()
  • NextFrameAsync()

If you are new to working with Coroutines, I recommend experimenting with them on your own to gain a better understanding.

A method, Awaitable.FromAsyncOperation(), has also been added for backward compatibility with the old API, AsyncOperation.

Using the destroyCancellationToken property

One of the conveniences of using Coroutines is that they automatically stop if the component is removed or disabled. In Unity 2022.2, the destroyCancellationToken property was added to MonoBehaviour, allowing you to stop asynchronous execution at the time of object deletion. It's important to remember that stopping the task through CancellationToken cancellation throws the OperationCanceledException. If the calling method doesn't return Task or Awaitable, this exception should be caught.

private async void Awake()
{
   try
   {
       await DoAwaitAsync();
   }
   catch (OperationCanceledException) { }
}

private async Awaitable DoAwaitAsync()
{
   await Awaitable.WaitForSecondsAsync(1, destroyCancellationToken);
   Debug.Log("That message won't be logged.");
}

private void Start()
{
   Destroy(this);
}

In this example, the object is immediately destroyed in Start(), but before that, Awake() manages to launch the execution of DoAwaitAsync(). The command Awaitable.WaitForSecondsAsync(1, destroyCancellationToken) waits for 1 second and then should output the message "That message won't be logged." Because the object is immediately deleted, the destroyCancellationToken stops the execution of the entire chain by throwing the OperationCanceledException. In this way, destroyCancellationToken relieves us from the need to create a CancellationToken manually.

But we can still do this, for example, to stop execution at the time of object deactivation. I will give an example.

using System;
using System.Threading;
using UnityEngine;

public class Example : MonoBehaviour
{
    private CancellationTokenSource _tokenSource;

    private async void OnEnable()
    {
        _tokenSource = new CancellationTokenSource();
        
        try
        {
            await DoAwaitAsync(_tokenSource.Token);
        }
        catch (OperationCanceledException) { }
        
    }

    private void OnDisable()
    {
        _tokenSource.Cancel();
        _tokenSource.Dispose();
    }

    private static async Awaitable DoAwaitAsync(CancellationToken token)
    {
        while (!token.IsCancellationRequested)
        {
            await Awaitable.WaitForSecondsAsync(1, token);
            Debug.Log("This message is logged every second.");
        }
    }
}

In this form, the message "This message is logged every second" will be sent as long as the object on which this MonoBehaviour hangs is turned on. The object can be turned off and turned on again.

This code may seem redundant. Unity already contains many convenient tools such as Coroutines and InvokeRepeating() that allow you to perform similar tasks much easier. But this is just an example of use. Here we are just dealing with Awaitable.

Using the Application.exitCancellationToken property

In Unity, async method execution does not stop on its own even after exiting Play Mode in the editor. Let's add a similar script to the project.

using System.Threading.Tasks;
using UnityEngine;

public static class Boot
{
    [RuntimeInitializeOnLoadMethod]
    public static async Awaitable LogAsync()
    {
        while (true)
        {
            Debug.Log("This message is logged every second.");
            await Task.Delay(1000);
        }
    }
}

In this example, after switching to Play Mode, the message "This message is logged every second" will be output to the console. It continues to be output even after the Play button is released. In this example, Task.Delay() is used instead of Awaitable.WaitForSecondsAsync(), because here, in order to show the action, a delay is needed not in game time but in real time.

Analogously to destroyCancellationToken, we can use Application.exitCancellationToken, which interrupts the execution of async methods upon exit from Play Mode. Let's fix the script.

using System.Threading.Tasks;
using UnityEngine;

public static class Boot
{
    [RuntimeInitializeOnLoadMethod]
    public static async Awaitable LogAsync()
    {
        var cancellationToken = Application.exitCancellationToken;
        
        while (!cancellationToken.IsCancellationRequested)
        {
            Debug.Log("This message is logged every second.");
            await Task.Delay(1000, cancellationToken);
        }
    }
}

Now the script will execute as intended.

Using with Event Functions

In Unity, some event functions can be Coroutines, for example, Start, OnCollisionEnter, or OnCollisionExit. But starting from Unity 2023.1, all of them can be Awaitable, including Update(), LateUpdate, and even OnDestroy().

They should be used with caution, as there is no waiting for their asynchronous execution. For example, for the following code:

private async Awaitable Awake()
{
   Debug.Log("Awake() started");
   await Awaitable.NextFrameAsync();
   Debug.Log("Awake() finished");
}

private void OnEnable()
{
   Debug.Log("OnEnable()");
}

private void Start()
{
   Debug.Log("Start()");
}

In the console, we will get the following result:

Awake() started
OnEnable()
Start()
Awake() finished

It is also worth remembering that the MonoBehaviour itself or even the game object may cease to exist while the asynchronous code is still executing. In such a situation:

private async Awaitable Awake()
{
    Debug.Log(this != null);
    await Awaitable.NextFrameAsync();
    Debug.Log(this != null);
}

private void Start()
{
    Destroy(this);
}

In the next frame, the MonoBehaviour is considered deleted. In the console, we will get the following result:

True
Flase

This also applies to the OnDestroy() method. If you make the method asynchronous, you should take into account that after the await statement, the MonoBehaviour is already considered deleted. When the object itself is deleted, the work of many MonoBehaviours located on it may not work correctly at this point.

It's worth noting that when working with event functions, it's important to be aware of the order of execution. Asynchronous code may not execute in the order you expect, and it's essential to keep this in mind when designing your scripts.

Awaitable event functions catch all types of exceptions

It's worth noting that Awaitable Event Functions catch all types of exceptions, which can be unexpected. I was expecting them to catch only OperationCanceledExceptions, which would have made more sense. But catching all types of exceptions makes them not suitable for use at this time. Instead, you can run async methods and manually catch the necessary messages, as shown in the earlier example.

private async void Awake()
{
   try
   {
       await DoAwaitAsync();
   }
   catch (OperationCanceledException) { }
}

private async Awaitable DoAwaitAsync()
{
   await Awaitable.WaitForSecondsAsync(1, destroyCancellationToken);
   Debug.Log("That message won't be logged");
}

private void Start()
{
   Destroy(this);
}

Because the component is deleted immediately on start, the execution of DoAwaitAsync() will be interrupted. The message "That message won't be logged" will not appear in the console. Only OperationCanceledException() is caught, all other exceptions can be thrown.

I hope this approach will be corrected in the future. At the moment, the use of Awaitable Event Functions is not safe.

Free movement across threads

As is known, all operations with game objects and MonoBehaviours are only allowed in the main thread. Sometimes it is necessary to make massive calculations that can lead to game freezing. It is better to perform them outside of the main thread. Awaitable offers two methods, BackgroundThreadAsync() and MainThreadAsync(), which allow moving away from the main thread and returning to it. I will provide an example.

private async Awaitable DoAwaitAsync(CancellationToken token)
{
   await Awaitable.BackgroundThreadAsync();
   Debug.Log($"Thread: {Thread.CurrentThread.ManagedThreadId}");
   Thread.Sleep(10000);
  
   await Awaitable.MainThreadAsync();

   if (token.IsCancellationRequested)
   {
       return;
   }
  
   Debug.Log($"Thread: {Thread.CurrentThread.ManagedThreadId}");
   gameObject.SetActive(false);
   await Awaitable.BackgroundThreadAsync();
   Debug.Log($"Thread: {Thread.CurrentThread.ManagedThreadId}");
}

Here, when the method starts, it will switch to an additional thread. Here I output the id of this additional thread to the console. It will not be equal to 1, because 1 is the main thread.

Then the thread is frozen for 10 seconds (Thread.Sleep(10000)), simulating massive calculations. If you do this in the main thread, the game will appear to freeze for the duration of its execution. But in this situation, everything continues to work stably. You can also use a CancellationToken in these calculations to stop an unnecessary operation.

After that, we switch back to the main thread. And now all Unity functions are available to us again. For example, as in this case, disabling a game object, which was not possible to do without the main thread.

Conclusion

In conclusion, the new Awaitable class introduced in Unity 2023.1 provides developers with more opportunities for writing asynchronous code, making it easier to create responsive and performant games. The Awaitable class includes a variety of waiting methods, such as WaitForSecondsAsync(), EndOfFrameAsync(), FixedUpdateAsync(), and NextFrameAsync(), which allow for more flexibility in the basic Player Loop of Unity. The destroyCancellationToken and Application.exitCancellationToken properties also provide a convenient way to stop asynchronous execution at the time of object deletion or exiting Play Mode.

It is important to note that while the Awaitable class provides a new way to write asynchronous code in Unity, it should be used in conjunction with other Unity tools such as Coroutines and InvokeRepeating to achieve the best results. Additionally, it is important to understand the basics of async-await and the benefits it can bring to the game development process, such as improving performance and responsiveness.

In summary, the Awaitable class is a powerful tool for Unity developers, but it should be used with care and in conjunction with other Unity tools and concepts to achieve the best results. It is important to experiment with it to better understand its capabilities and limitations.


Written by deniskondratev | Software Engineer / С++ / C# / Unity / Game Developer
Published by HackerNoon on 2023/01/27