Hacker News new | ask | show | jobs
by rednab 1461 days ago
Unity (the game engine, not the desktop environment) uses this concept heavily, although they use the slightly older IEnumerator instead of IEnumerable ¹).

It usually takes people a while to wrap their heads around it and (also hinted at in the article) forgetting to add "yield return" in front of an IEnumerator is a common mistake, but it is an awesome tool to flatten out callback hell or complicated every-frame checks.

¹) https://docs.unity3d.com/Manual/Coroutines.html

6 comments

[...] although they use the slightly older IEnumerator instead of IEnumerable ¹).

They are not older or newer, they belong together. IEnumerable has only one method GetEnumerator() which returns an IEnumerator. If you have an IEnumerator, you can iterate over the sequence once [at a time], if you have an IEnumerable you can obtain as many IEnumerator as you need or want.

FYI, IEnumerator is the thing that can be enumerated, and IEnumerable is the thing that can give you an IEnumerator when you call GetEnumerator() on it.

Both are supported as return types with yield return, in fact if you use IEnumerable, the generated iterator's implementation will be:

     IEnumerator GetEnumerator() => return this;
I started on a Unity Squad based RTS prototype in 2021 using generic state machines, but I eventually shelved that project due to art issues.

This year I began another RTS prototype, but this time using coroutines instead of state machines for doing procedural animation, AI controllers etc.

It doesn't take that long to learn and once you get how it works it makes development a lot nicer. Just a side note, when using these in Unity make sure to look at More Effective Coroutines (free/paid versions available) on the asset store to make sure you're not unnecessarily allocating memory.

Yeah, it's a nice way to kludge in yielding/cooperative execution without bringing in the threading syntax of async/await.

It's still a bit of a miss match though. Delay instructions and waiting for other coroutines are implemented as type unsafe yield return values. Still, a pretty good hack. It ends up with a lot less garbage than async/await.

Not really. Tasks can be zero garbage, IEnumerable cannot. A 'task' in C# is just something that has a GetAwaiter() method, which gives you a callback on when something has happened.

This is how ValueTask is implemented in newer .NET versions which is a struct, but you can roll your own.

Yes really. It's huge effort to make this happen. ValueTasks bring their own gotchas, like not being able to be awaited multiple times. Yielding from an existing IEnumerator is free while every await is a chance to cause garbage with a new awaitable. Even Task.Yield() allocates garbage (in Unity at least) and trying to get Unity lifecycle events as cheap and safe awaitables is not really feasible. At least at the moment.
> Delay instructions and waiting for other coroutines are implemented as type unsafe yield return values

Can you elaborate? I was under the impression that `YieldInstruction` is a well-defined type.

You would use the base IEnumerator that yields object types. `YieldInstruction`s are a type that the Unity coroutine runtime will recognize but you can yield anything. Some have meaning like YieldInstruction, Coroutine, CustomYieldInstruction and others that Unity will recognize as some special command to wait before the next iteration. As far as I know there is no exhaustive public list. You can also just yield anything else from int to stream.

Yielding a final value that isn't a yield instruction is a technique to return a value from a coroutine. You can pull the final return value from the IEnumerator's Current property. Hacks on hacks but it gets the job done.

Yes, unfortunately due to coroutines having been designed before async/await was a thing.

But seeing how async/await is a thing since like a decade, this is another display of Unity doing things their own way for no good reason.

There's actually a lot of good reasons to use generators over async/await. It's not feasible to use only async/await in Unity. Besides the inherent garbage with the design of Tasks, the fact that threading isn't even a topic for generators makes the UX a lot easier for newer/novice devs.
They simulate concurrency with coroutines because due to design all code that uses the engine functions should run in main thread, so you can't use Tasks or Threads.
The threading thing is a common requirement for game engines, though. Unreal also assumes engine functions are called only on the game thread (unless they're specifically marked otherwise).
Tasks can be constrained to the main thread - you just need to replace the TaskScheduler and/or the SynchronizationContext.
You can use async Tasks as long as you keep them on the main thread. They end up making a lot of garbage though.
That's what ValueTasks are there for, no?
ValueTasks help but they can only be awaited once. The Unity runtime knows how to handles the same IEnumerator yielded by multiple parent routines.

Cancellation is another gotcha with Tasks. The main way to cancel tasks is to use exceptions as control flow. This causes cancellation to be very expensive. You can still use cancellation tokens and check for IsCancelled and finish your task cooperatively but then you're kind of doing thinks in an non-canononical way.

Unity also hasn't done the work to make cheap unity lifecycle delay calls such as Task wait for end of frame, (although they could). The work arounds are expensive.

Is that Unity-specific stuff? From my experience, cancellation in idiomatic C# is mostly done via tokens, not exceptions.
IIRC there's a little more work needed for unity, but Cysharp has you covered (https://github.com/Cysharp/UniTask)