If you haven't seen it, Cysharp's UniTask is a plugin for Unity allowing efficient, alocation-free, and versatile asynchronous coding in games.
No more IEnumerator, no more coroutines.
This post isn't going to do a ton of explaining how to use UniTask beyond the very basics - the linked page contains a ton of sample code and it'd just be a reduplication of efforts. This post is intended to complement the readme file and introduce the basic concepts of UniTask. The few examples I will give will be for replacing other, outdated approaches with UniTask-based ones.
The Origins of IEnumerator-based Coroutines
Coroutines, as implemented with IEnumerator in Unity code, have been the standard for asynchronous code execution in Unity for decades. It's not that they were the best way to write asynchronous code - but for quite a while, they were really the only way! Any other approach to asynchronicity would require you as the programmer to keep track of your own state, know when to resume execution, and how to resume it at the right part.
The IEnumerator interaface in C#, however, was never designed to be used this way. IEnumerators were intended for enumerating over collections (hence the name). So when you do something like this:
List<float> myList = new List<float>();
myList.Add(1f);
myList.Add(10f);
foreach (var thisFloat in myList.GetEnumerator()) {
Debug.Log($"The number is {thisFloat}");
}
That's an IEnumerator being used for its actual purpose. C# infers the ".GetEnumerator()" part of this code when you iterate directly over a class which implements IEnumerable (as List does), but I've included it here for extra clarity. Behind the scenes, the List class has code that looks something like this (obviously massively simplified):
public IEnumerator IEnumerable.GetEnumerator()
{
for (int index=0; index < internalArray.Length; index++) {
yield return internalArray[index];
}
}
If you're only familiar with IEnumerator in the context of Unity coroutines, seeing them used this way is probably pretty jarring. But this is exactly what they're supposed to do: iterate over a number of items, yield return each item in turn, until it finishes the list. Each item that is returned can be used by the loop in the code that called the enumerator in the first place. Because of the way this logic must operate, there by necessity must be two sections of code, both running at once, handing off to each other until the list is done.
In order to implement this, the designers of C# had to make it possible to pause execution of a piece of code until called upon again. This wasn't the main point of IEnumerator, but it was a required part, and it's exactly what the creators of Unity needed when they wanted to implement asynchronous functionality. So, let's say you wanted to fade your mesh out across 100 frames, you could do this:
IEnumerator FadeOut() {
for (int f=0; f<100; f++) {
renderer.color = new Color(1f,1f,1f,1f - (float)f/100f);
yield return null;
}
}
void Update() {
if (Input.GetKeyDown(KeyCode.F)) {
StartCoroutine(FadeOut());
}
}
As far as C# is concerned, it's iterating over a list of 100 items, all of which happen to be "nothing". StartCoroutine(), then, behaves a lot like the for loop in our first snippet, iterating over this list - only, it waits a frame each time it gets back that "null". (Behind the scenes, it's actually more like adding the list to a tracker and calling MoveNext() manually on each running coroutine every frame, which is why any Debug.Logs in a coroutine have at their root a MoveNext() call)
So What's the Problem?
Well, because the coroutine hijacks IEnumerator to do something that IEnumerator was never designed for, it comes with some drawbacks like any hack.
Allocations. Returning a value each frame (null, 0, what have you) means that memory has to be allocated to hold that value. Allocations contribute to garbage collection freezes.
Clumsy syntax. The usage of a non-descriptive "IEnumerator" return type, and the unintuitiveness of returning null (or 0) each frame that doesn't do anything, makes them annoying.
Nesting coroutines is fraught with confusion. You want coroutine A on object X to call coroutine B on object Y which then calls coroutine C on object C? It can be done, but good luck trying to track down edge cases and weird bugs in the process.
Coroutines require an active GameObject to execute. This is a common problem hit by newbies who want to deactivate their object for a few seconds and then bring it back. You can work around this, but you have to run the coroutine on some other object.
Canceling coroutines is iffy. The most reliable way to cancel a running task with coroutines is to check for some cancellation flag in your procedure code itself, which is just annoying boilerplate.
You can't try/catch over a yield return. Because yield return pushes execution back up the chain, this would put the code in an unworkable state where it doesn't really know what to do if an exception is thrown. So, it's disallowed.
Limited context when getting a stacktrace. You can never see any context beyond the coroutine itself when you output logs, as the useful data just ends with the call to MoveNext() by the engine. This can make tracing code flow in coroutines difficult, especially if coroutines are getting nested in each other.
No return values for procedures that generate data. A common usage of asynchronous functions is to spread out some sort of processing over the course of a number of frames, and then you have some data. But getting that data back to the code that needed it in the first place involves some sort of trickery or other. Wouldn't it be easier if you could just return the thing?
The last one in particular rules out IEnumerator-based coroutines for a lot of use cases. The others can be worked around, but it would take a significant effort to work around not being able to return things, especially if you have an asynchronous method that needs to return multiple values to different processes. Callbacks are the only reliable way I've found, but those add their own complexity.
All of these limitations mean that Unity developers have a tendency to use coroutines (and, by extension, asynchronous logic) only when there is no other option. So devs simply avoid writing asynchronous code in general, and the end result of this is one of the little ways that Unity games tend to get disparaged about, which is yet another source of "little freezes". Processes that take a fraction of a second are just sort of done in the middle of code, and the rest of the game just has to wait. Or, as the player sees it, "freeze".
So can we run asynchronous code without these drawbacks, and in so doing, write code that is asynchronous by default?
Async/Await to the rescue
In C# 5.0 (first released in 2012, and later incorporated as part of that version into Unity's Mono runtime), the keywords async and await were introduced. Unlike IEnumerator, these were actually intended to be used for general asynchronous programming. With these keywords, you can await bits of code as they do their thing, and then pick up where you left off.
One thing that is not immediately apparent is how to integrate this into Unity's frame-based execution cycle. With coroutines, you'd call StartCoroutine, and that's your hook into the engine. Unfortunately, since C# isn't written for Unity, the implementation of async/await into the compiler left this puzzle piece to be solved. Once you're in the middle of an async situation this isn't much of an issue - tasks can await each other no problem - but getting the task kicked off in the first place is an issue at the top level, and at the bottom level we have the problem of figuring out how to wait for the next frame. We have the sandwich filling but no crust.
UniTask to the Rescue
As you may have suspected, this is where UniTask comes in. UniTask is primarily a set of convenient functions that allow all this async/await goodness to operate smoothly within the Unity framework. In our async/await sandwich, UniTask is the crust.
On the top level, getting an async function kicked off is commonly accomplished with:
public void StartLongProcess() {
UniTask.Void( () => WaitTenSeconds() );
}
This is about the most complicated that UniTask syntax gets. Once you're in the async system, it's all buttery smooth. Now the async function itself would look like this:
public async UniTask WaitTenSeconds() {
Debug.Log("Starting the 10 second timer");
await UniTask.Delay(10f);
Debug.Log("10 seconds elapsed");
}
Here you can see the bottom layer, which is one of several approaches UniTask offers to await the game engine itself.
Note the UniTask return type and the async keyword there - those are necessary. I'll get into other return types in a second, but this one is the equivalent of returning a void (since this one doesn't return anything).
There are many other things in UniTask you can await at this level depending on your needs, and I recommend glancing through the readme on the git repository linked above for a complete listing. Trust me when I say that anything in the engine you need to wait for, UniTask has you covered.
Compatibility with other C# "tasks"
A great many C# packages use async/await without using UniTask. One of the nice things of UniTask, being built on top of C#'s native system, is that it's fully compatible with these systems. UniTask-based tasks can await other tasks, and other tasks can await UniTasks.
A great example of this is the places where Unity itself uses async code, such as the Addressables system. Addressables.LoadAssetsAsync, for example, is not written with UniTask, but you can await that function entirely seamlessly.
Returning Data
This is where UniTask's syntax shines brightest in my opinion. Let's imagine an extremely simple use case. Say you want to throw up a dialog box to the user and know whether they clicked OK or Cancel. Most Unity developers would instinctively not even consider using asynchronous code for this job, because the required boilerplate for just getting this essentially boolean value back from the user is seriously not worth it.
But with UniTask, it really is:
public async UniTask LongProcessWithUserConfirmation() {
await DoSomeLongWork();
bool didUserClickOK = await DialogBox.instance.ConfirmationDialog("Are you sure you want to continue?");
if (!didUserClickOK) {
return;
}
await DoSomeOtherLongWork();
}
The implementation of a reusable confirmation dialog is where more legwork is done, but it's still pretty straightforward. (Note that this example is using a singleton pattern for this dialog box) For pieces of functionality that are reusable (like a common dialog box), getting the usage of it down to one very simple line is worth doing the legwork once on the functionality itself. And most functionality doesn't require nearly this much legwork!
public class DialogBox : MonoBehaviour {
public static DialogBox instance;
public Button btnOK;
public Button btnCancel;
public Text txtDialogMessage;
private void Awake() {
instance = this;
btnOK.onClick.AddListener(ClickOK);
btnCancel.onClick.AddListener(ClickCancel);
}
public async UniTask<bool> ConfirmationDialog(string message) {
didClickOK = false;
didClickCancel = false;
txtDialogMessage.text = message;
await UniTask.WaitUntilValueChanged(this, x => x.didClickOK || x.didClickCancel);
return didClickOK;
}
private void ClickOK() {
didClickOK = true;
}
private bool didClickOK = false;
private void didClickCancel() {
didClickCancel = true;
}
private bool didClickCancel = false;
}
Getting the system to respond to user input is probably the most complicated use case. Most of the time, you'll be responding to an animation delay, a resource loading process, or a response from the web - and in those situations, awaiting is much cleaner.
But Wait - there's easy multithreading!
This is, in my opinion, the most undersold feature of UniTask, which is that it offers the easiest way for game devs to implement multithreading, period. And here it is:
public async UniTask SomeCalculationFunction() {
// main thread
await UniTask.SwitchToThreadPool();
// worker thread
await UniTask.SwitchToMainThread();
//and main thread again
}
It is insane how easy it is to make this very powerful. Multithreaded code in Unity remains subject to limitations (most UnityObject-derived classes can't be used at all from other tasks, for thread safety), but being able to briefly duck back into the main thread to access those things and duck back out into your worker thread with those simple lines of code is heavenly. Load a bunch of bytes in from a file on a worker thread; jump to the main thread to turn those bytes into your texture; then go back into the worker thread to do any other processing you have to do.
Async by Default
The fact that async code can now be written in Unity should shift your entire workflow to treat asynchronous code as the default. An async function doesn't have to await anything, and if it doesn't then there is no real difference between it and a regular native function - it'll just finish immediately. But that function can also decide that awaiting is necessary for whatever reason, and the code that called it will patiently wait.
This is especially useful in polymorphic code - any abstract method that might possibly want to await anything should be written as a UniTask type. Some derived classes will take advantage of it, some won't, and that's perfectly fine. If you're writing your code as async by default, you can always choose to add or remove awaiting functionality at any point down the line.
There are still many functions that logically must return instantly, and that's fine. Anything that has to happen within one frame to make sense should not be asynchronous. But as you start getting into an async-by-default mindset, you'll find these situations to become fewer and fewer as time goes on.
MantaMiddleware packages and UniTask
You'll notice that most packages in MantaMiddleware have UniTask as a prerequisite, and that is because these package all adhere to this async-by-default philosophy. This is not a complete list, but for example:
ObjectPool
ObjectPool uses async/await for all spawning and destroying functions. This allows for the possibility of instantiating from assets (which can incur loading delays), as well as time-delayed user-created functions like fading the object in or out, or waiting for death animations.
EventCaster
EventCaster uses async/await everywhere. Every event cast may be awaited, including those with multiple listeners, and requests can be awaited while returning an array of their results after they're done.
Comentarios