Async / await in Unity

Salute, Khabrovsk. Very little time remains before the start of the course “Unity Game Developer”, in connection with this, we have prepared for you another interesting translation.


async Unity already works without any plugins or wrapping Task coroutine simulating asynchronous behavior by checking completion on each frame. But it’s still a kind of magic. Let’s go a little deeper into this topic.

(Note: I still do not have a perfect understanding of all the engine compartment subtleties async/await in C #, so I will try to complement the foregoing, as my understanding deepens.)

Example

Suppose I need a button that, when clicked, would play a classic box animation. But there is one catch: the button must go into an inactive state (darken by .interactable) until the box will not finish the rotation.

For reasons of code cleanliness, I want to use awaitwaiting for the completion of the rotation of the box, and immediately add the line restoring interactable, instead of something like creating an object containing a coroutine that checks the state on each frame to start this task. Having an obvious linear code in one place is a big plus in terms of readability. Such code is nice to write.

The following simple, compact code implements the required task. (async methods also appear on the Unity delegate list, 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;
    }
}

Here are some questions you should have asked:

  • “Who” “performs” while and the rest of the method? How is it magic checked in the next frame, while the call SpinAndDisableButton executed only once and there are no Update or corutin to restart.
  • What is the timing of each run?
  • What Task.Yield()? This seems to be the key to everything that happens here. I suppose you’re used to yield return null in coroutines. A good guess would be to try again in the next frame, but you say this through the C # enumerator. The wording for yield is similar, and even the behavior is similar.

Sync context

Check out this article, about how the rest of the program can be captured and continued as we want (for example, in which thread? In the calling thread or in the thread that starts the task?)

Then you will notice that Unity also has such a concept, but it is not visible to us at first glance.

Compared to a regular C # program

Everything is not quite the same as in regular C # programs. Take a look at the official documentation await. await implies returning to the caller of the object Task. The caller can continue until he needs the result of execution, and he can no longer afford to do something else at this time (it does not matter whether the task is multithreaded or not), except await. This chain can go on until you finally get to Main. Where if we also meet async, there is another await generated by a compiler that immediately requests the result.

Now back to our SpinAndDisableButton. Where is the return going? await? In Unity we do not have Main, since it is deeply hidden in the engine code. The question is essentially the same as who performs Update, LateUpdate etc. it PlayerLoop An API that supports the engine code as part of the game loop to provide an orderly rendering of objects and everything that is executed in the frame. But before, this did not bother us, since so far these entry points have returned void.

Now await returns the execution point to someone in the hope that he can continue from that point later, at a certain point, automatically. Then game cycle continues, till await is waiting. Otherwise, we would not see the rotating box at all, if we really waited until the box finished the animation, because the animation could not end if the frame change did not continue. So what will happen in the next frame?

This is the pseudocode that we want to receive in a frame different from the one in which we pressed the button and thereby disabled it:

for (игровой цикл)
{
    if(анимация коробки закончена)
    {
    	Включаем кнопку.
    }
  
    Анимация продолжается.
    Отправить матрицу преобразования коробки на рендеринг.
    Мы видим обновленный рендеринг коробки.
}

Debugging

Include more logs in the code, and we will try step-by-step debugging.

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 is as follows:

  • The frame in which we start the animation is preceded by Update and LateUpdatebecause the mouse click is due EventSystem and GraphicRaycaster from UGUI Unity, which, as it turns out, has an order of execution in Update above any of your scripts.
  • while waiting arises magically in each frame from this place, by timing after Updatebut before LateUpdate. As if we spawned Corutin, but we did not!
  • Another interesting point is that in the first frame, where we just started playing the animation, there are two Awaiting, since the UGUI event system appeared even earlier, hinting that await subscription, takes effect immediately without the need to recap anything in the next frame
  • In the old days, we had to put a check in Update or something like that. await Subscription eliminates the need for this.

It remains to demystify the “pending object”, which I did not fully solve, but at least I see how it works, because it was intentionally encoded.

In the first frame, where I clicked on the button, there is nothing strange. Update (the same magic MonoBehaviour Update) EventSystem uses Input The API sees my click, after which it scans my button on the canvas and sees that it can do something. Then it is called in public async void and reaches this line. (You can also use these ExecuteEvents manually! This is an auxiliary static class.)

In the next frame where the magic happens, the call stack can show us exactly who processes the check for us every few frames. I do not quite understand what is happening here. (although this works)

When i checked UnitySynchronizationContext, a lot seems to be called from the engine code, so I can do nothing but guess.

But there is something called WorkRequestthat represents each of your expected unfinished business. I assume the return await properly registered in a “game” way, which is compatible with the personnel paradigm and ensures that all safe code is locked in the main thread so that you can fully do something after any await, because it will be done at the right time in the frame.

Task.Yield ()

This method has a very confusing description:
docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.yield?view=netframework-4.8

Returns
Yieldawaitable

A context that, as expected, will asynchronously transition back to the current context while waiting. If current SynchronizationContext not equal to NULL, it is considered as the current context. Otherwise, the task scheduler associated with the currently running task is treated as the current context.

Remarks

you can use await Task.Yield(); in an asynchronous method to make the method terminate asynchronously. If the current synchronization context (SynchronizationContext object) exists, it will poison the rest of the method execution back to this context. However, the context will decide how to prioritize this work relative to other work that might be pending. The synchronization context, which is present in the UI stream in most UI environments, often gives priority to work placed in context higher than input and rendering. For this reason, do not rely on await Task.Yield(); will keep the UI responsive. For more information, see “Useful Abstractions Implemented with ContinueWith” on the “Parallel Programming with .NET” blog.

English is not my native language, and I think that neither C # yield returnnor Task.Yield() do not reflect the function that they perform. But let’s move on to the definition of the method.

The alleged use case in the note seems to suggest that the method is not really asyncbut you would like to turn it into asynctherefore you need await (expect) somewhere in it, therefore Task.Yield() – The perfect scapegoat. Instead of immediately terminating the method (remember that async will not magically turn the method into asynchronous, it depends on the contents of the method), now you force its to be asynchronous.

But in our case, the recipient of the context is not an ordinary context, but UnitySynchronizationContext. Now Task.Yield() has a more useful function that effectively continues to work in the next frame. If it were ordinary SynchronizationContext, I suppose this could lead to an infinite loop in our example, since it would execute continue straight to while again and again.

He returns YieldAwaiterthat the caller can use to continue. As our log shows, “Awaited” was recorded at the beginning of each frame, because the context (the code next to await) was saved on this aviter. UnitySynchronizationContext does some magic, waits for the frame and uses this aveter to continue, then it reaches while and returns a new one again YieldAwaiter. This is likely to continue adding new WorkRequest for the code earlier in each frame as a waiting task, while while will not false.

UniTask

There is a popular package UniTaskwhich completely eliminates SynchronizationContext and introduces a new type of easier task UniTaskthat directly binds to PlayerLoop API Then you can choose the time when the check should occur in the next frame. (Initialization? Late update?) But, essentially, you know that await works without any plugin. This will be useful with Addressables, where AsyncOperationHandle may be .Taskwhich you can use with await.

To view

This is a good conversation that shows that Await Already works great, but there are pluses to the reasonably used corutin. You can pay more attention to this slide to sort it out a bit if you are still confused.


We create a network shooter in space in an hour and a half.


Similar Posts

Leave a Reply

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