Awaitable

Published 3 months ago


Note

This tutorial only applies to Unity 2023.1 or later.

Unity provides developers with an easy solution to calling a method after a delay. This method is called Invoke. It has a pretty obvious benefit: It's easy to understand and use.

But unfortunately, there are far more drawbacks which make this method a candidate for obsoletion.

  • It relies on string dependencies, and therefore is prone to typos which may not be caught until runtime
  • It does not support parametered methods
  • It relies on a distinct method to exist
  • It uses reflection to find the method you provide
  • You can't return a value

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

Consider the following example:

[SerializeField] private new Renderer renderer;

private void Start()
{
    Invoke("ChangeColorAfterDelay", 2.0f);
}

private void ChangeColorAfterDelay()
{
    this.renderer.material.color = Color.red;
}

Let's go through some of the problems listed above:

  • Invoke accepts a string for the method name. This is called a string dependency. String dependencies mean the actual name of the method in code, and the string, are unrelated to each other making refactoring extremely difficult. It is also prone to typos which you will not encounter until you run the game.
  • The ChangeColorAfterDelay method cannot accept any parameters. Its logic is fixed, which means if we wanted to change to a different color, we'd have to create another method - creating duplicate code.
  • It relies on a method existing in the first place. This does not always make sense. It means that other methods in our class can access this method and that may or may not be what we want to allow. It means we cannot have a single part of the code fire-once-and-forget after a delay.
  • Most developers know that reflection is slow. Fetching a method by name is akin to finding an object by its tag. It has to search every method name until it finds a match. This is horribly inefficient.

The only saving grace…

One of these problems can be averted pretty easily. But only one. We can use the nameof operator which will convert a symbol name to a string at compile time. This means if we decide to rename our method using quick-refactor tools, all instances of that name will be changed.

[SerializeField] private new Renderer renderer;

private void Start()
{
    Invoke(<mark>nameof(ChangeColorAfterDelay)</mark>, 2.0f);
}

private void ChangeColorAfterDelay()
{
    this.renderer.material.color = Color.red;
}

However, this does not change the fact that we are passing a string - which means reflection is being used internally. Besides, we still have the outliers: No parametered methods, no inline invocation, no return values.

The solution

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 code to this:

[SerializeField] private Renderer _renderer;

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

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

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

The cancellation token

What is a cancellation token?

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.

You may have noticed, in the codeblock above, the use of the property destroyCancellationToken - this cancellation 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 async Awaitable Start()
{
    await 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