async already works in Unity without any kind of plugins or coroutine wrapping the Task and pseudo-async it by checking completion every frame. But it is kind of magical. Let's try to dig into it a bit more.

(Note : I am still confused by the whole C# async / await inner workings, I should add more once I understand everything throughly.)

Example

I would like a Button that when clicked, would play a classic Animation on the box. But there is a catch : the button should turn into disabled state (greyed out via .interactable) until the box finished spinning.

But I would like a cleaner code, I want to use await until the box finished spinning and immediately put a line that interactable is restored right after. Instead of something like spawning coroutine host game object to check the state every frame and do work afterwards. Having code in the same place linearly is a big plus to readability. It's very fun to write such code.

With a code this simple, it already could achieve what I said. ( async method still show up in Unity's delegate picker just fine, don't worry.)

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

public class SpinBox : MonoBehaviour
{
    public Button button;
    public Animation ani;

    public async void SpinAndDisableButton()
    {
        ani.Play();
        button.interactable = false;
        while (ani.isPlaying == true)
        {
            await Task.Yield();
        }
        button.interactable = true;
    }
}

Questions that should come to your mind :

  • "Who" is "running" the while and the rest of method? Why is it getting checked in the next frame magically when the call to SpinAndDisableButton is only once, and there is no Update or coroutine whatsoever to repeatedly run it.
  • What is the timing of each run?
  • What is Task.Yield() ? It seems to be the key to everything here. I assume you used to yield return null in coroutines. That is a clever to say try again the next frame but you are talking via C# enumerator. The "yield" wording is similar, and even the behaviour is similar.

Synchronization context

Check out this article : http://hamidmosalla.com/2018/06/24/what-is-synchronizationcontext/ how the rest of the program could be captured and continued in a way we like (e.g. on which thread? on the caller or on the thread that run the task?)

Then we will see Unity also have such a thing too but it is not visible to us at first glance.

Compared with regular C# program

It's not quite like normal C# program. Looking at this official await documentation. await means return to the caller as a Task. The caller could go on, until it need the execution result and can't afford to do something else in the mean time anymore (it doesn't matter the task is truly multithreaded or not) then caller can await. This chain can continue until you finally arrive at Main. Where if also async, there is an another await generated by the compiler that ask for result immediately.

Now look at our SpinAndDisableButton again. await returns to who? We have no Main in Unity as it is tucked deep in the engine code. The question is now the same as who is running Update, LateUpdate and so forth. It is the PlayerLoop API that the engine code run as a part of game loop to ensure orderly render submission and things goes in frame unit. But we used to not care since until now those entry point returns void.

Now the await is returning the execution point to that someone in hope that it could continue from the same point later, at a certain moment, automatically. Then continue with the game loop while await that. Otherwise we wouldn't seeing a spinning box at all if we really await until the box finishing the animation, because animation can't finish unless the frame goes on. What exactly would happen the next frame?

This is a pseudo code we want to achieve in a frame other than the frame that we pressed the button and disabled the button :

for (gameLoop)
{
    if(box animation finished)
    {
    	Button is enabled.
    }
    Animation advances.
    Send box's transformation matrix for rendering.
    We see a new box's rendering.
}

Debugging

Putting more logs into the code and we will try debug stepping.

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

public class SpinBox : MonoBehaviour
{
    public Button button;
    public Animation ani;

    public async void SpinAndDisableButton()
    {
        Debug.Log($"Started async {Time.frameCount}");
        ani.Play();
        button.interactable = false;
        while (ani.isPlaying == true)
        {
            await Task.Yield();
        }
        button.interactable = true;
        Debug.Log($"Finished async {Time.frameCount}");
    }

    public void Update()
    {
        Debug.Log($"Update {Time.frameCount}");
    }
    
    public void LateUpdate()
    {
        Debug.Log($"Late Update {Time.frameCount}");
    }
}

The update order turns out like this :

  • The frame where we start the animation, it came before Update and LateUpdate because the mouse clicking is thanks to EventSystem and GraphicRaycaster of Unity UGUI, which happens to have Update execution order before any of your scripts.
  • The while awaiting seems to occur magically every frame from that point, the timing is after Update but before LateUpdate. It like we have spawned a coroutine but we haven't!
  • One interesting point is that at the first frame where we just issue the animation playing, there is 2 Awaiting in the same frame since UGUI event system came even before. Suggesting that await "subscription" is immediately effective without need to summarize or anything in the next frame.
  • In the old days we would have to put a check in Update or something. This await subscription elimiates the need of that.

What's left is to demystify the "awaiter", which I didn't get it completely but at least I see it works because it was intentionally coded in.

The first frame where I clicked the button there is nothing strange. Update (the same magical MonoBehaviour Update) of EventSystem uses Input API and see that I clicked, it pseudo-raycast into my canvas button and see that it could do something. Then it invoke into this public async void and arrive at this line. (You can manually use this ExecuteEvents too! It is a helper static class.)

The next frame around where magic happen, the call stack could reveal exactly who is working the check for us every other frames. This is the point where I didn't exactly understand what's happening here. (though it works)

When checked the UnitySynchronizationContext, many seems to be called from engine code so I can't really do anything but guess.

But there seems to be something called WorkRequest that represent each of your unfinished business awaited. I could guess that the await returning is properly registered, in a "game" way, that is friendly with frame paradigm and ensure all safe code locked in main thread, so you can truly do anything after any await because it is at the right moment in the frame.

Task.Yield()

This method got really confusing summary :

https://docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.yield?view=netframework-4.8

Returns
YieldAwaitable

A context that, when awaited, will asynchronously transition back into the current context at the time of the await. If the current SynchronizationContext is non-null, it is treated as the current context. Otherwise, the task scheduler that is associated with the currently executing task is treated as the current context.

Remarks

You can use await Task.Yield(); in an asynchronous method to force the method to complete asynchronously. If there is a current synchronization context (SynchronizationContext object), this will post the remainder of the method's execution back to that context. However, the context will decide how to prioritize this work relative to other work that may be pending. The synchronization context that is present on a UI thread in most UI environments will often prioritize work posted to the context higher than input and rendering work. For this reason, do not rely on await Task.Yield(); to keep a UI responsive. For more information, see the entry Useful Abstractions Enabled with ContinueWith in the Parallel Programming with .NET blog.

I am not English native, and I think neither C# yield return or Task.Yield() convey the function it performs. But let's go with the method definition.

An intended use case in the remark seems to say that the method is actually not async but you would like to turn it into async so you need an await somewhere in it, therefore Task.Yield() is the perfect scapegoat, or something. Instead of finishing the method right away (remember that async won't magically turn the method to async, it depends on the content of the method) now you force it to be async.

But in our case the context receiver is not an ordinary context but UnitySynchronizationContext. Now the Task.Yield() has a more useful function that effectively continue things the next frame. If it was an ordinary SynchronizationContext, I guess it could produce infinite loop in our example program since it would continue to the while again right away.

It returns a YieldAwaiter that the caller could use to continue. As evidence from our log, "Awaited" was logged every frame onwards becuase the context (code next to the await) was saved into this awaiter. UnitySynchronizationContext do something magical and wait a frame and use this awaiter to continue, then it hit while and once again return a new YieldAwaiter. This probably continue adding a new WorkRequest for the code earlier every frame as a pending task until the while is false.

UniTask

There is a popular UniTask (https://github.com/Cysharp/UniTask) package that eliminates SynchronizationContext altogether and make a new kind of lighter task UniTask that binds to PlayerLoop API directly. You could then choose the timing when should the awaiting check would happen in the next frame. (Initialization? Late update?) But essentially, you know await works without any kind of plugin. It will be useful with Addressables where AsyncOperationHandle could be .Task that you can use with await.

Watch

This is a good talk that show await already works, but there are pros of coroutines that justify its usage. You could stare at this slide for a while to get a grip if you are still confused.