Looking into Unity's async/await
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.
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 toSpinAndDisableButton
is only once, and there is noUpdate
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 toyield 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
andLateUpdate
because the mouse clicking is thanks toEventSystem
andGraphicRaycaster
of Unity UGUI, which happens to haveUpdate
execution order before any of your scripts. - The
while
awaiting seems to occur magically every frame from that point, the timing is afterUpdate
but beforeLateUpdate
. 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 thatawait
"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 await
ed. 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 useawait 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 onawait 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
.
Read
Here are more links that will reinforce your knowledge of C# await
pattern :
- await anything; | .NET Parallel Programming (microsoft.com)
- Extending the async methods in C# | Developer Support (microsoft.com)
- JacksonDunstan.com | How Async and Await Work
- How to use Async-Await instead of coroutines in Unity3d 2017 | Steve Vermeulen
The last one highlights a particularly important weakness of Unity's iterator block pattern where you cannot return value or handle exception good enough, and the gotcha you may get when transitioning from non async
method and just plainly call async
method. If you are not careful, your exception will be swallowed into the Task
and that is scary. There are some consideration of choosing between async void
versus async Task
.
This one is so good! It demystifies that async
and await
disappeared into a regular method returning Task
, while replacing the body with a state machine. The state machine's MoveNext
will advance an integer each time. Each await
in your body are sliced into sections belonging to each integer. And finally returning the result at the final state. You will see more clearly too how GetAwaiter()
works and how your custom one will fare in the state machine.
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.