Unity's Awaitable API

Oliver Oliver • Published one year ago Updated 3 months ago 0 Comments


Some of you may remember my earlier post in which I talked about a better way to Invoke. Well, that was a little over 2 years ago now and a lot has changed with Unity and so I'd like to revisit this post with some more up-to-date information.

First of all, I'll preface this by saying I'm not going to reiterate over why Invoke is bad - the reasons on my first post are still very much true. What I'd like to amend is the “solution” where I said to await various Task values.

The latest1 Unity alpha, 2023.1a, introduces a new type called Awaitable. Sadly, this type is not yet documented. But thanks to the help of a very active forum post about the future of .NET in Unity, coupled with some of my own personal experimentation, I'm happy to say that the devs at Unity have made a good decision.

If you've previously used the fantastic library UniTask, then you're in for a treat: Unity basically implemented this themselves. That's right, the same features are now brought to you directly by the same people who failed to give us a stable Render Pipeline, and decent networking solution! But I digress, I'm actually not here to talk smack about Unity for a change.

The problem

Anyway, if you remember from my first post, I said you could use a flow similar to this, instead of using Invoke:

[SerializeField] private Renderer _renderer;
 
private async void Start()
{
    await ChangeColorAsync(Color.red);
}
 
private async void OnCollisionEnter(Collision collision)
{
    await ChangeColorAsync(Color.green);
}
 
private async Task ChangeColorAsync(Color color)
{
    await Task.Delay(2000);
    _renderer.material.color = color;
}

However this code has two pretty major flaws:

  • Tasks are managed by the .NET runtime, not by Unity, and so if the object running the task is destroyed (or if the game leaves play mode in the editor), the task will continue running because Unity has no way to cancel it.
  • We've introduced async void, which is a whole host of problems that you can read about here.

Now, we could solve problem 1, and the way we do that is by passing a cancellation token that signals cancellation when the object is destroyed - or when the game stops running, which we can do like so:

<mark>private CancellationTokenSource _cancellationTokenSource = new();</mark>

private void OnDestroy()
{
    <mark>_cancellationTokenSource.Cancel();</mark>
}

private void OnApplicationQuit()
{
    <mark>_cancellationTokenSource.Cancel();</mark>
}

We can then pass this token in the call to Task.Delay like so:

private async Task ChangeColorAsync(Color color)
{
    await Task.Delay(2000<mark>, _cancellationTokenSource.Token</mark>);
    _renderer.material.color = color;
}

But this starts to become unwieldy the more behaviours you write that incorporate async/await, and of course this doesn't solve the whole async void problem.

Cancellation tokens are used to send a signal to tasks which instruct them to prematurely halt at their earliest convenience.

This is generally pretty useful because it's what allows the user to cancel an asynchronous operation such as a large file copy, or a download. In the case of Unity, this token becomes extremely important because we need to send that signal at critical moments, such as the game object being destroyed, or when exiting Play mode in the editor.

These tokens should be used whenever possible, otherwise it can lead to very weird side effects where operations on a game object will try to continue even after the object is destroyed, leading to exceptions.

The solution

As mentioned earlier, starting with 2023.1, there's now a new type called Awaitable. This class behaves quite similarly to Task, there is a method called GetAwaiter which returns a type that includes all the necessary API to be compatible with the ever-so-magical await keyword. Which means we can convert our previous code to this:

[SerializeField] private Renderer _renderer;

private async <mark>Awaitable</mark> Start()
{
    await ChangeColorAsync(Color.red);
}

private async <mark>Awaitable</mark> OnCollisionEnter(Collision collision)
{
    await ChangeColorAsync(Color.green);
}

private async <mark>Awaitable</mark> ChangeColorAsync(Color color)
{
    await Task.Delay(2000, <mark>destroyCancellationToken</mark>);
    _renderer.material.color = color;
}

And in fact, because Awaitable behaves very much like Task, we can remove the async modifier from the Start and OnCollisionEnter methods and have it return the result of the method calls directly. So our code can now become:

private Awaitable Start()
{
    return ChangeColorAsync(Color.red);
}

private Awaitable OnCollisionEnter(Collision collision)
{
    return ChangeColorAsync(Color.green);
}

However doing this causes the stack trace to break if an exception were to be thrown. So stick with the async/await version.

The cancellation token

You may have noticed, in the codeblock above, the removal of my own _cancellationTokenSource field. That's because with this new Awaitable API, the MonoBehaviour class now has its own property called destroyCancellationToken - this token automatically signals a cancellation whenever the object is destroyed, or when play mode ends, or frankly whenever Unity decides it should be signalled. This token should always be passed wherever there is an option to pass it. That means pretty much everything in the Task class (methods such as Task.Run, or Task.Delay), because those tasks are handled by the .NET runtime. Awaitable itself however? Well…

Awaitable is handled by the engine, not the runtime

Since Awaitable itself directly implements IEnumerator, Unity can handle it as if it were a coroutine. Let's imagine we have a coroutine that changes the colour of a material every single frame, forever:

private Material _material;

private void Awake()
{
    _material = GetComponent<Renderer>().material;
}

private void Start()
{
    StartCoroutine(ChangeColor());
}

private IEnumerator ChangeColor()
{
    while (true)
    {
        float r = Random.Range(0f, 1f);
        float g = Random.Range(0f, 1f);
        float b = Random.Range(0f, 1f);
        _material.color = new Color(r, g, b, 1);
        yield return null;
    }
}

For the epileptic among you, please do not run this code.

Converting this code to async (awaiting Task.Yield in place of yield return null), we can achieve the same effect:

private Awaitable Start()
{
    return ChangeColorAsync();
}

private async Awaitable ChangeColorAsync()
{
    while (true)
    {
        float r = Random.Range(0f, 1f);
        float g = Random.Range(0f, 1f);
        float b = Random.Range(0f, 1f);
        _material.color = new Color(r, g, b, 1);
        <mark>await Task.Yield();</mark>
    }
}

And because Unity handles this as if it were a coroutine, it even pauses execution of our loop when we pause the game in the editor. We don't even need use the destroyCancellationToken property in this specific case because the call to Task.Yield means control gets yielded back to Unity and Unity can handle situations where objects get destroyed, or when the game is paused / over.

Return values

Awaitable even comes with its very own generic version which can be used to return values from async methods, again, very much like Task<T>. Take for example the following code which waits for 1 second, before returning the tag of the game object involved in the collision, which is then logged inside OnCollisionEnter:

private async Awaitable OnCollisionEnter(Collision collision)
{
    string tag = await GetTagAsync(collision);
    Debug.Log($"The tag of the collided object is {tag}");
}

private async Awaitable<string> GetTagAsync(Collision collision)
{
    await Task.Delay(1000<mark>, destroyCancellationToken</mark>);
    return collision.gameObject.tag;
}

Okay, simple example, but I hope you understand why this is a big deal. Not much more to say about this, except I am sure I will have a million uses for it.

Continuous functions

The old coroutine system (that is, methods returning IEnumerator) almost worked with Unity messages, for example you could make Start a coroutine by having the following code:

private IEnumerator Start()
{
    yield return new WaitForSeconds(1);
    Debug.Log("The game started 1 second ago.");
}

But such a thing couldn't be done on continuous functions like Update and OnCollisionStay, since the whole point of those specific messages was that they'd be called every frame and spreading the responsibility of the methods over several frames pretty much defeated the point. Which - to be honest - sucks, because you couldn't do something like this:

private <mark>IEnumerator</mark> Update()
{
    if (Input.GetKeyDown(KeyCode.Space))
    {
        <mark>yield return new WaitForSeconds(1);</mark>
        Debug.Log("Space was pressed 1 second ago.");
    }
}

And I know for sure you've needed something like this in the past. I see beginners wanting to do this sort of thing all the time. The only way you could accomplish this was by starting a whole new coroutine in a separate method, like so:

private void Update()
{
    if (Input.GetKeyDown(KeyCode.Space))
    {
        StartCoroutine(LogThatSpaceWasPressed());
    }
}

private IEnumerator LogThatSpaceWasPressed()
{
    yield return new WaitForSeconds(1);
    Debug.Log("Space was pressed 1 second ago.");
}

This is no longer necessary! With the new Awaitable API, even continuous functions like Update can be made async. The above code can now be written like so:

private <mark>async Awaitable</mark> Update()
{
    if (Input.GetKeyDown(KeyCode.Space))
    {
        <mark>await Task.Delay(1000, destroyCancellationToken);</mark>
        Debug.Log("Space was pressed 1 second ago.");
    }
}

This completely removes the need for an entire method just to have a short delay before performing an operation. I, for one, am a huge fan.

Awaiting AsyncOperation

One other huge benefit to this new API is that the existing “fakesync” (fake async), as I tend call it, has been given new extension methods that add support for the await keyword.

I'm sure you've done some asynchronous scene loading in the past, by doing something like this:

private void Update()
{
    if (Input.GetKeyDown(KeyCode.Space))
    {
        StartCoroutine(LoadNewScene());
    }
}

private IEnumerator LoadNewScene()
{
    AsyncOperation asyncLoad = SceneManager.LoadSceneAsync("NewScene");

    while (!asyncLoad.isDone)
    {
        yield return null;
    }
}

Yes I just copied the code from the Unity docs please don't @ me.

This sort of thing was - to be honest - a pain in the ass. This is not an intuitive way to implement async code and frankly it's ugly. Well, Awaitable saves the day once again here. We can now simply just await the call to LoadSceneAsync, and the job is done:

private async Awaitable Update()
{
    if (Input.GetKeyDown(KeyCode.Space))
    {
        await SceneManager.LoadSceneAsync("NewScene");
    }
}

This sort of thing also draws much inspiration from the aforementioned UniTask library which wraps AsyncOperation in much the same way. So much nicer, don't you agree?

Conclusion

I don't trust that this API won't change, after all it's still in Alpha and they'll no doubt change things making this blog post obsolete. If that ever happens, I'll fix my errors once the API is in a stable release. Constantly changing this post to update nightly modifications is more effort than I'm prepared to invest.

However for once, I'm actually proud of Unity for taking a step in the right direction. They are bringing their API into the modern .NET age, and if the forum post is anything to trust - it's only uphill from here. Coupled with the upcoming promised migration to CoreCLR and the MSBuild ecosystem means great great things are in store for Unity and the developers who use it.


  1. as of the time of writing