This article aims to teach about using coroutines, which generalize a cooperative multitasking model in your code using state machines.
It doesn't make sense to use a thread-pool for everything. It puts strain on the OS scheduler, usually severely complicates code because of the locking involved, and due to the same can even harm performance. Also, sometimes a thread is just overkill. An alternative to threads when we need multitasking capabilities is to use cooperatively multitasked code. This article aims to teach one pattern for doing such multitasking in your own code.
Conceptualizing this Mess
Coroutines are methods that essentially break up their execution into multiple parts. Each time you call the coroutine, the next part of the task is performed. They essentially can break in the middle of execution and return to where they left off the next time they are called.
C# will generate its own specialized coroutines, referred to as iterators in C#, which divide execution into multiple parts using the
yield return statement. As you're probably aware, each time the iterator is moved, the routine picks up where it left off just after the
Such routines are not magic. They are not special routines. Under the covers of the iterator that C# generates is a state machine in the coroutine itself where each step (each section leading up to a
yield return) is a different state. The returned
IEnumerator<T> implementation holds the current state which it passes into the coroutine (implemented by
MoveNext()) each time so that it knows where to pick up the execution again.
Coding this Mess
We'll start by writing our own coroutine from scratch, and then show you how to "cheat" and get C# to generate a "good enough" coroutine for you, despite it being a bit of a kludge/hack.
The following coroutine is a bit contrived, it counts from 1 to 100 and back to 1. Each time you call it, it returns the next number in the series. We can't use a loop inside the routine to accomplish this, so we break it out under a switch case, using a state machine:
static int Coroutine1(Coroutine1Token token)
token.State = 1;
if (1 == token.Value)
token.State = 2;
The first thing you might notice is
Coroutine1Token. There are two main things this class does. It holds any parameters we need to pass to the function, which we don't need in this case, as well as any working state (such as
token.Value in this case), and the
token.State integer itself which tracks where we are in the coroutine.
Here's how it's called:
var tok = new Coroutine1Token();
while (-1 != (c = Coroutine1(tok)))
Console.Write(c.ToString() + " ");
Here, we create a new
Coroutine1() needs in order to function. Then we call
Coroutine1() in a loop (like you might do with any coroutine) letting it process each part and it updates the state in
Coroutine1Token as necessary. Each iteration of the loop we write the value returned from
Obviously, you'd do something more useful under each state. However, as you can see, the routine is kind of complicated to build due to the state machine. If we're willing to deal with a kind of ugly interface to it, we can get the C# compiler to do all the heavy lifting using iterators. Enter
static IEnumerable<int> Coroutine2()
for (var i = 1; i <= 100; ++i)
yield return i;
for (var i = 99; 0 < i; --i)
yield return i;
This does the exact same work and returns the same results as
Coroutine1(). As you can see, it's quite a bit more intuitive to create. The downside is it's not incredibly intuitive to use. This routine causes the C# compiler to generate something very much like
Coroutine1() under the covers. It unrolls the loops and breaks them up if they contain a
yield statement, and does similar with
if blocks and the like. The end result is a much less complicated way to create a state machine and a coroutine that drives it. The interface however, leaves something to be desired. Here's how we call it:
foreach (var i in Coroutine2())
Console.Write(i.ToString() + " ");
See how we have to call it using
foreach? That's a little weird, especially in cases where you need a
while loop or something instead of using
foreach. In those cases, you must use the enumerator directly:
Console.WriteLine("Coroutine2() using while:");
using (var e = Coroutine2().GetEnumerator())
Console.Write(e.Current.ToString() + " ");
See what I mean about not being very intuitive? That's the price you pay for ease of implementation.
So all of this is well and good but what does it have to do with multitasking?
Essentially, every time we call a coroutine, it performs a (hopefully) minor amount of work before yielding time to the caller. Because it yields time in granular chunks, it means it won't lock up your calling thread for the duration. Due to this, you can call a coroutine to do one fragment of work, and then move on to the next thing, even other coroutines.
Points of Interest
Fibers are another way to abstract this, and I use that technique in my Lex project. They are like tiny bare bones threads that are scheduled cooperatively instead of preemptively.
- 19th March, 2020 - Initial submission