Coroutines in Unity are a way to run expensive loops, or delays in execution, without having to involve multithreading.
That's right – although it's commonly believed that coroutines are multithreaded operations, they in fact run on the
main thread. But have you ever asked yourself why coroutines return IEnumerator
? What does that even mean? We'll take
a
look at how they work, and I hope to explain just how genius they are.
Coroutines are not multithreading
You can prove this to yourself by calling
Thread.Sleep
from within a coroutine.
using System.Collections;
using System.Threading;
using UnityEngine;
public class Coroutines : MonoBehaviour
{
private void Start()
{
StartCoroutine(MySimpleCoroutine());
}
private IEnumerator MySimpleCoroutine()
{
Debug.Log("Hello from the coroutine!");
Thread.Sleep(5000);
yield break; // necessary for IEnumerator
}
}
Attach this behaviour to a game object, and you will see that execution sleeps for 5 seconds on the current thread –
which is the main thread. The game freezes entirely. If coroutines ran on a separate threads, this Sleep
call would
not interfere with rendering.
What does it mean to yield
?
Let's step away from Unity and start a blank Console Application, and explain what makes the yield
keyword so special.
Let's define a method which returns
IEnumerable<int>
called
GetNumbers
. This method will allocate a list and then loop 10 times. In the loop we will add the current loop
iteration to the list, and then Thread.Sleep
for 1 second. Afterwards, we will return the list.
using System.Collections.Generic;
using System.Threading;
internal static class Program
{
private static IEnumerable<int> GetNumbers()
{
var list = new List<int>();
for (int iterator = 1; iterator <= 10; iterator++)
{
list.Add(iterator);
Thread.Sleep(1000); // 1 second
}
return list.ToArray();
}
}
Now, let's call this method from Main
and print the results to the console.
using System.Collections.Generic;
using System.Threading;
internal static class Program
{
private static void Main()
{
<mark>IEnumerable<int> numbers = GetNumbers();</mark>
<mark>foreach (var number in GetNumbers)</mark>
<mark>{</mark>
<mark> Console.WriteLine(number);</mark>
<mark>}</mark>
}
private static IEnumerable<int> GetNumbers()
{
var list = new List<int>();
for (int iterator = 1; iterator <= 10; iterator++)
{
list.Add(iterator);
Thread.Sleep(1000); // 1 second
}
return list.ToArray();
}
}
Running this code, you will notice something. It waits 10 seconds before finally outputting the values 1-10 immediately,
all at once. This is because in order to foreach
over a collection, it has to know what that collection is. But it can
only know what the collection is once the method returns. But the method only returns once its for
loop is complete,
which means it has to Thread.Sleep
10 times, 1 second each, adding each element to the list before the method finally
gives the result back to the caller: our foreach
.
Let's adjust the code to remove the creation of the list, and the return statement at the end. Instead, place a
yield return
statement inside the loop. We will return the value of iterator. The GetNumbers method now looks like
this:
private static IEnumerable<int> GetNumbers()
{
for (int iterator = 1; iterator <= 10; iterator++)
{
<mark>yield return iterator;</mark>
Thread.Sleep(1000); // 1 second
}
}
The foreach
loop in Main
does not need to be touched – we still want to iterate over the result of this method.
Except, running this code, you will immediately see the difference this made.
Instead of sleeping 1 second – 10 times – before finally returning control back to the caller (leading to a 10 second
sleep in total), what we are doing is quite literally “yielding” control back to the caller with the next value of
iterator
in every iteration of the for
loop. This gives a chance for the foreach
body to execute, print the value,
and then return back to GetNumbers
to essentially ask “what next?” – at which point the current thread sleeps for 1
second, and the for
loop continues to the next value.
Wait. That's an IEnumerable
. Unity uses IEnumerator
!
Correct it does! IEnumerable
is the base interface for all things that are – well – “enumerable”. Lists, arrays,
queues, stacks, dictionaries, anything which can be “enumerated” implements IEnumerable
. IEnumerator
, on the other
hand, is a type which is responsible for defining how such enumerables should be enumerated.
In short, IEnumerable
is a collection of values. IEnumerator
is responsible for iterating over such collections.
So how does this relate to coroutines?
Diving deep into how foreach
works
You may have noticed that types such as arrays and lists all define a
GetEnumerator
method. This
method returns a type which implements IEnumerator
, and it's responsible for implementing the way that the current
collection must be enumerated.
Save for arrays, because those compile slightly differently, a foreach
actually compiles to a while
loop which runs
for as long as
IEnumerator.MoveNext
returns
true. You can see this in action
here!
Ignoring the initialisation and try-catch
, our source code:
foreach (int number in list)
{
Console.WriteLine(number);
}
...compiles to:
List<int>.Enumerator enumerator = list.GetEnumerator();
while (enumerator.MoveNext())
{
Console.WriteLine(enumerator.Current);
}
enumerator.Dispose();
What is actually happening here, and why does it work?
We can browse the .NET source code for the
List<int>.Enumerator
struct to see what's going on. Let's remove all the noise, clean it up so it's more readable, and
just focus on the important parts.
public struct Enumerator : IEnumerator<T>
{
private readonly List<T> _list;
private int _index;
private T _current;
public T Current => _current;
public bool MoveNext()
{
if (_index < _list.Count)
{
_current = _list[_index];
_index++;
return true;
}
_index = _list.Count + 1;
_current = default;
return false;
}
// ... more code here. not important to focus on
}
We see that the enumerator tracks the current index, as well as the value at that index. The Current
property just
returns the value of the _current
field. But every call to MoveNext
checks if the index is within the bounds of the
list. If it is, it assigns _current
to the value at _index
, increments _index
, and returns true.
So the while
loop we saw above is simply asking the enumerator to move to the next index, and then accesses Current
.
Until, of course, _index
reaches the end of the list, at which point it returns false and the loop is complete.
A Stack<T>
enumerator behaves a similar way, except it iterates backwards. Looking at
the source code for StackEnumerator
,
line 361 shows a decrement: --_index
. This is the reason a foreach
calls GetEnumerator
– because different
collection types need to be enumerated differently.
What does this have to do with coroutines?
Whenever you call StartCoroutine
, Unity adds the IEnumerator
to a collection of coroutines on which it needs to call
MoveNext
in every iteration of its own game loop. We can see this in action if we were to create our own yield
instruction in Unity. Below is an example of a custom WaitForTime
– which accepts a TimeSpan
to indicate how long
execution should be paused for.
Essentially, it is a custom version
of WaitForSecondsRealtime
,
but instead accepting a TimeSpan
instead of a float
for the duration.
The constructor calculates the end time by adding the TimeSpan
parameter to the current time. Then in MoveNext
, we
simply check whether or not the current time has elapsed the end time. It returns true for as long as _endTime
is in
the future.
using System;
using System.Collections;
public class WaitForTime : IEnumerator
{
private readonly DateTime _endTime;
public WaitForTime(TimeSpan time)
{
_endTime = DateTime.Now + time;
}
/// <inheritdoc />
public bool MoveNext() => DateTime.Now < _endTime;
/// <inheritdoc />
public void Reset() { } // not important
/// <inheritdoc />
public object Current => null;
}
If we yield return
a new instance of this in a coroutine, it works as expected:
private IEnumerator MySimpleCoroutine()
{
Debug.Log("Hello from the coroutine!");
yield return new WaitForTime(TimeSpan.FromSeconds(5));
Debug.Log("Slept for 5 seconds!");
}
We can shove in a few Debug.Log
calls to see when Unity is calling things.
You can see the message count for the MoveNext
call shoots up to the thousands extremely quickly. This is because
Unity is calling MoveNext
on every coroutine in the collection every frame.
No, seriously, coroutines are not multithreading
Despite the names of the types – WaitForSeconds
, WaitForSecondsRealtime
, WaitForEndOfFrame
, and our very own
WaitForTime
– containing the word “wait”, there's no actual waiting going on here; MoveNext is called every single
frame and there is zero pause between each call (we can see evidence of that in our Console). The only reason there is
an apparent pause before “Slept for 5 seconds!” is logged is because of the yield. We constantly yield control back to
Unity's game loop, and so long as our instruction's MoveNext returns true, control never continues beyond that point. At
no point do Wait
instructions hang the thread. There is no Thread.Sleep
call.
As an aside, that means – if you really wanted to, you could create an endless instruction which “waits” forever, just
by defining MoveNext
to unconditionally return true.
public class WaitForever : IEnumerator
{
public bool MoveNext() => true;
public void Reset() { }
public object Current => null;
}
Conclusion
I was planning to write up a small example project which implemented a system very similar to Unity's coroutines to demonstrate how Unity's game loop actually handles them. While I did get it working, the code ended up being far more long-winded than I originally anticipated. Frankly, this makes me respect coroutines even more; I underestimated the level of wizardry that they involved. So much so that I felt it went far beyond the scope of this article.
However! If enough of you are interested in another post in which I elaborate on that code, perhaps I'll write about it in future. Let me know in the comments.
While coroutines have their pitfalls – and trust me there are many – they certainly are a very clever feature. One which I feel is often underappreciated.
I wrote a follow-up post! You can read it here.
I would love to see the code!