Skip to content

How does coroutine execution work?

Juju Adams edited this page Oct 31, 2021 · 25 revisions

This document explains the basic principles behind coroutine execution, and how coroutines are generated using function calls. If you're looking for an explanation of the inner workings of the GML syntax extension, please see How do we extend GML?

 

Execution

Coroutine execution in this library centres around the "coroutine root struct", a data container that both manages the execution of the coroutine as well as stores coroutine state (the variables that you read and write whilst the coroutine is executing). Whenever you write code for a coroutine, it is automatically scoped to run inside the scope of the coroutine. This means you can write code without worrying about whether you're accidentally accessing data elsewhere - in a sense, every variable in a coroutine is local. The root struct is also the target scope for executing coroutine methods.

When a coroutine is created, it is registered in a global list of coroutines that are processed every frame. As a result, coroutines are executed automatically and do not need additional code to run them. You can, of course, cancel or pause the coroutine at any point using the relevant methods if you wish. When a coroutine completes it is removed from that global list meaning the memory allocated to it will be garbage collected, provided that your code isn't holding a reference to it.

As mentioned above, coroutines are executed automatically every frame. Unfortunately, GameMaker doesn't make this as easy as it could and we need to actually hook into an instance's Step event somewhere for coroutines to do their thing. This is the purpose of CoroutineEventHook(). Note that this function also has code to handle async events - it's the same issue there, we need an instance to execute the event hook function for coroutines to receive any async events at all.

 

Generation

When a coroutine is defined, the root struct is given a sequence of instructions it must carry out. Instructions can simply be "run this block of GML", or the can be flow control (loops and branching), or they can be behaviours that require the root struct to wait for further input. Instructions are added to the root struct using function calls that look like this:

__CoroutineFunction(function() //Instruction 1
{
    i = 0;
});

__CoroutineWhile(function() //Instruction 2
{
    return (i < 6);
});

__CoroutineFunction(function() //Instruction 3
{
    show_debug_message("Six messages!");
    show_debug_message("(i=" + string(i) + ")");
});

__CoroutineYield(function() //Instruction 4
{
    return i;
});

__CoroutineEndLoop(); //Instruction 5

__CoroutineFunction(function() //Instruction 6
{
    show_debug_message("Done!");
});

We call a sequence of function calls that adds instructions to a coroutine a "generator function". Note how the coroutine generator function requires a step to start the while-loop __CoroutineWhile() and another instruction to end the loop __CoroutineEndLoop(). This generator function is equivalent to the following standard GML:

var i = 0;
while (i < 6)
{
    show_debug_message("Six messages!");
    show_debug_message("(i=" + string(i) + ")");
    //YIELD...THEN has no equivalent!
}
show_debug_message("Done!");

It's obvious by comparing the generator function and its standard GML equivalent that the standard GML is much more compact! We solve this problem by extending what syntax can be used in GML - please see How do we extend GML? for more information.

At any rate, coroutines are built using these generator function calls. All these functions are doing is pushing data into an instruction array, or changing which instruction array the data is being pushed into. We start off by writing data into the coroutine's root struct, but when we enter into the while-loop we instead start pushing data into the while-loop. After we end the while-loop we return to pushing instructions into the root struct's array again.

We can visualize this by writing this out as nested arrays (I'm abbreviating function calls here for simplicity):

root : [
    i = 0,                        Instruction 1
    while (i < 6) : [             Instruction 2
        "Six messages!",          Instruction 3
        "(i=" + string(i) + ")",
        YIELD i,                  Instruction 4
    ],                            Instruction 5
    "Done!"                       Instruction 6
]

By breaking down instructions into these nested arrays, we can interpret our coroutine as a machine that iterates over its instruction array until it runs out of instructions to execute. Each instruction inside the array is in itself a machine. Whatever that inner machine needs to do, it must complete its task before the outer machine can continue through its own instruction array. Given that the root struct stops entirely when it completes its instructions but a while-loop might execute its loop hundreds of times, it stands to reason that there are multiple types of machine.

These machines aren't real, after all. They're virtual machines, a term you may have heard of before. What describing here is a very very simple kind of VM, but it's a VM regardless.

In fact, there's a machine for every command. If you're curious, you can poke at the code that controls each type of machine by looking through the System folder inside the library code. It's not essential to understand how everything works in minute detail to use this library, but I think it's interesting nonetheless.

The major defining feature of coroutines (in general, not just for this library) is that they can pause and resume execution. Going back to our machine metaphor, each machine has a memory of 1) whether it has completed and, if not, 2) what it was doing before the coroutine was paused. This is easier than it sounds. Each coroutine has a root struct that contains all of the variables so that'll stick around, and each machine can store its own state-tracking variables. For example, the while-loop in our example has a variable in it that tracks where it got up to in the instruction array. When the coroutine resumes, the coroutine as a whole finds which inner machine still has work to do and resumes execution using the state variables for that machine.

That explains how instructions are organised and executed, but what about specific lines of code? Typically for a virtual machine, every single line of code gets processed somehow. This is how GameMaker's virtual machine works, in fact, every line of code gets broken down into a series of instructions. This library doesn't do anywhere near as much heavy lifting since we can get GameMaker to do it for us! We can lean into the anonymous function feature (function() {}) of GMS2.3 to prepare arbitrary code for execution whether the coroutine needs it.