I’ve used MEC for years and it’s been great, especially the paid version which lets me easily pause all coroutines across my game, or stop all of them, manage what and how they update, etc. I was first introduced to it at a company I worked at that used it.
I agree with Baste that in general, Coroutines are just way more user-friendly. Debugging Async/Await has always felt like a nightmare. I am interested in learning more about the ways of Async/Await though, but feel like the removal of Coroutines wouldn’t make much sense unless they can replace them with an equally or more simple and user-friendly system to use.
Alright let’s settle this: I don’t really understand why people think async is not user friendly so let’s do an example.
This is a city builder. When you build a house, a process will run in the background to build walls, a pool, a slide and a roof. This is a quirky house, role with it.
We can build the walls and the pool side by side, but the slide can only be build when both are completed, and the roof only when the walls are.
Here’s coroutines:
public class House : MonoBehaviour
{
private bool wallsBuilt;
private bool poolBuilt;
public void Build()
{
StartCoroutine(BuildWalls);
StartCoroutine(BuildPool);
}
// Build walls in 2 to 5 seconds
private IEnumerator BuildWalls()
{
yield return new WaitForSeconds(Random.Shared.NextSingle() * 3 + 2);
// Build Walls
StartCoroutine(BuildRoof);
wallsBuilt = true;
StartCoroutine(BuildSlide);
}
// Build pool in 3 to 4 seconds
private IEnumerator BuildPool()
{
yield return new WaitForSeconds(Random.Shared.NextSingle() + 3);
// Build Pool
poolBuilt = true;
StartCoroutine(BuildSlide);
}
// Build roof in 4 seconds
private IEnumerator BuildRoof()
{
yield return new WaitForSeconds(4);
// Build Roof
}
// Build slide in 3 seconds
private IEnumerator BuildSlide()
{
if (!wallsBuilt || !poolBuilt)
yield break;
yield return new WaitForSeconds(3);
// Build Slide
}
}
We have a lot of annoying quirks going on: the initial Build method doesn’t display everything it’s doing, the coroutines it launches have a “hidden agenda” so to speak. We also need to make some variables to keep track of ours walls and pool. There’s also no good way to cancel this operation midway if we want to allow our players to do that.
public class House : MonoBehaviour
{
public async void Build()
{
// Build the pool in the background
var buildPool = BuildPool();
// Wait for the walls to be completed
var buildWalls = await BuildWalls();
// Build roof in the background
BuildRoof();
// Wait for the pool to be completed
// This takes 0 ticks if it was already done
await buildPool;
// Build slide in the background
BuildSlide();
}
private async Task BuildWalls()
{
await Awaitable.WaitForSecondsAsync(Random.Shared.NextSingle() * 3 + 2):
// Build walls
}
private async Task BuildPool()
{
await Awaitable.WaitForSecondsAsync(Random.Shared.NextSingle() + 3):
// Build pool
}
private async Task BuildRoof()
{
await Awaitable.WaitForSecondsAsync(4):
// Build roof
}
private async Task BuildSlide()
{
await Awaitable.WaitForSecondsAsync(3):
// Build slide
}
}
Okay, so we had to think a little harder on our Build method, but look how clean everything is. No hanging variables, everything that build does is easily readable, and it can be easily extended.
Easily make our Build method awaitable. Just do this:
public async Task Build()
{
var buildPool = BuildPool();
var buildWalls = await BuildWalls();
var buildRoof = BuildRoof();
await buildPool;
await Task.WhenAll(buildRoof, BuildSlide());
}
No skin off our teeth.
We can also make everything Cancellable. Another benefit (I won’t show an example, just put CancellationToken in every method).
But most importantly: our designer comes in and says they changed their mind and now want the slide to be buildable ONLY when the roof is also finished.
Gimme 5 seconds:
public async Task Build()
{
var buildPool = BuildPool();
var buildWalls = await BuildWalls();
await Task.WhenAll(buildPool, BuildRoof());
await BuildSlide();
}
Modular, single responsibility, beautiful code.
It couldn’t be more user friendly if you tried. What we need is not another paradigm, what we need is education.
To add to that, we can also return values, like the amount of bricks that were needed for each build step, instead of having global variables (noOfRoofBricks, noOfPoolBricks and so on), something that makes debugging a lot easier
Async is better, however it has some easily encountered pitfalls that Unity’s coroutines don’t have.
Failing to await a Task and having your exceptions eaten. (Not all IDEs warn against this).
Failing to cancel a Task and having its lifetime continue, for example, into Edit Mode. Something many wouldn’t expect.
There are other aforementioned threading footguns that most beginners won’t run into too.
It’s super powerful (neater, flexible, etc) and I’m using async everywhere, but teaching it to someone requires more effort to ensure they totally understand exception handling and cancellation (mostly destroyCancellationToken). While that might not sound like much, things can get so much worse than coroutines ever could, and it can be quite a lot when learning and experimenting.
I’m not fussed if they remove coroutines, but they really need to provide some thorough tutorials to point to when they do.
To be clear, I’m not in favor of removing coroutines. I think they are easier for beginners and ideal for introductory tutorials. However, I believe they are far more limited compared to async/await, especially with the introduction of the new Awaitable.
While coroutines are simpler, I don’t agree with the notion that they are better for coding. Anyone who has been using Unity for a while should invest time in moving from coroutines to async/await. Over time, I think most developers will have a better programming experience from this transition.
With how many beginners the community has had to help over the years because they discovered coroutines in introductory tutorials that completely failed to explain how and why they work I’m firmly of the opinion that beginners should be sticking to just Update. Once new developers have a firm understanding of the basics and are no longer struggling to get basic logic working then they can start playing around with them. By that point they shouldn’t be a beginner any longer.
I am surprised at how many people like Coroutines. Although I guess it is not unexpected considering Unity has been for years promoting Coroutines in tutorials without explaining what is going on behind the scenes. Outside of Unity Coroutines hardly even exist in game development.
One option is for Unity to provide a package with a few different options something like a Coroutine except make it class-based with explicit updating. Include a few different States Machines including a Timer-Based State Machine, and a Chain of Responsibility State Machine example.
Chaining Coroutine may seem easy but the more complex your chain of state changes is the harder these kinds of chains are to reason about or debug. They also make more advanced techniques like network rollbacks more complex than necessary.
can’t wait for reddit optimized bros do complete 180 if/when unreal adopts c++ standardized coroutines
(yes, they are probably going to shove even this into standard (if they didn’t already, all that’s missing at this point is webview))
That would be crazy, but I doubt it. I believe they’ll still be using IL2CPP so their own compiler to WASM. It’s a shame there’s not more ‘native’ support for web, although they do a pretty good job all things considered.
Even using IL2CPP, unity can also wrap HandleAllocator from emscripten for better interop. And implement some basic object such as wrapping Promise with Task
I had actually no idea! That’s great actually… I kindof can’t believe that with all the tutorials I’ve watched learning Unity that never came up.
Guess I was right about one thing at least: we desperately need education!
I will still hold firm on my plight for async though
Something I haven’t mentioned but which is an absolutely AMAZING benefit of async over coroutines is that StartCoroutine needs to be called from the MonoBehaviour itself, while the async method can be called from anywhere. It is also not tied to a gameobject, and that can lead to amazing performance benefits.
That house we’re building in our game, well: our city builder needs to be able to build 100.000 of them, maybe more. Ideally we want this object to be as lightweight as possible. So no scripts! We could destroy our script when it finished building, but then we’re running into a whole thing of garbage collection spikes. Much better would be to have this whole thing run in a static Builder class (or dare I say Factory method) and the code can just… run. No garbage collection whatsoever, we’ll use the GameObject.destroyedCancellationToken all the same. To my knowledge, these kindof optimizations are impossible with StartCoroutine, and have you jump over heaps of hoops to optimize.
Neither problem requires UniTask to solve.
UniTask only provides a task tracking window (and maybe you can count some convenience extensions) in addition to what Awaitable and the token I mentioned has for those problems. I’d love Awaitable to have one too, but really, it’s got little to do with what we were talking about, and isn’t a preventative measure.
Using UniTask on the very project I’m working on and when I entered the project I found multiple cases of unawaited tasks that were suppressing exceptions, and my colleagues are quite senior. The first project I heavily invested in tasks I had done the same. It requires specific experience and learning to know how to not screw it up.
I believe the problem that UniTask solves and that was mentioned above is memory allocation. Awaitable is quite nice, but it’s a class (just like Task) and therefore leads to GC allocations every time an instance is created. UniTask, in the other hand, is a struct and can avoid many more allocations.
Edit: it’s in their GitHub repository’s “bio”:
Provides an efficient allocation free async/await integration for Unity.
It’s about efficiency and low allocations, not necessarily replacing Coroutines.
Allocations are not a problem, the GC collecting the memory after the class is not needed, is. In that context, Awaitable is very performant as it is pooled from a pool that is stored in a static ThreadLocal class, that also offers lazy instantiation.
Another benefit is that the Awaitable calls a non managed implementation of Awaitable in the native code when that is needed for native async operations in Unity C++ space, so it is more performant for the native engine part of Unity, something that UniTask cannot obviously do.
So compared to coroutines UniTask is certainly more efficient, but it won’t be more efficient than Awaitable in scenarios where async operations from the native part of Unity are needed or because it is a struct, compared to Awaitable.
As a side note, tasks “swallowing” exceptions is not a problem is a feature, as this was decided in .NET framework 4.5, because throwing an exception in tasks that never completed and “unwrapped” would terminate the process, and this wouldn’t make sense as it is code that is not needed for the program to run successfully. If someone wants to handle those exceptions, can still do it by using the TaskScheduler.UnobservedTaskException event.