Async / await in Unity
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 await
waiting 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 callSpinAndDisableButton
executed only once and there are noUpdate
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 toyield 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
andLateUpdate
because the mouse click is dueEventSystem
andGraphicRaycaster
from UGUI Unity, which, as it turns out, has an order of execution inUpdate
above any of your scripts. while
waiting arises magically in each frame from this place, by timing afterUpdate
but beforeLateUpdate
. 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 thatawait
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 WorkRequest
that 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
YieldawaitableA 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 onawait 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 return
nor 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 async
but you would like to turn it into async
therefore 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 YieldAwaiter
that 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 UniTask
that 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 .Task
which 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.