Skip to content

Coroutine Tasks

Dandielo edited this page Apr 21, 2022 · 1 revision

Task and Coroutines

One of many features we received in recent years for C++ were coroutines, although they are already known for much longer in the programming space.

The most important thing they allow us to do is to suspend code execution at specific points and resume them at a later point. This seems unnecessary initially but results in interesting solutions for some problems. Most commonly it is used in multi-threaded scenarios, where a single task (coroutine) depends on the results of another task.

In the old days we would either wait with a lock for the previous task to finish or split the task into multiple callback methods / function calls. Everything still works, but we can now do it in a more elegant way.

Quick Overview

In the simplest way if a coroutine suspends, we can access its handle and store it either in the suspending coroutine to continue after it finishes or send it to a "task manager" that will handle the resuming of such coroutine.

In the example below, we are in a coroutine function, and we are going to await another coroutine function.

// Inside of coroutine "load_resource"
{
    // .. other things done previously
    Resource res = co_await find_resource(a_variable_with_a_path);
}

The moment we await the "find_resource" coroutine, we store the handle to our current "load_resource" corotine inside the new one, so when it ends it will resume it. The flow goes like this:

  • Execute: load_resource
  • Await: find_resource
    • Store Handle: load_resource in find_resource
  • Execute: find_resource
    • Finish: find_resource
  • Resume: load_resource
    • Finish: load_resource

Because of how this works we can chain any number of coroutines, if we change the thread / execution context in one of the coroutines it will resume the remaining coroutines also on that thread.

Because of this property we can create simple rules to follow when working with tasks.

For example, some of these rules are:

  • The resource_tracker.load_resource() method may resume on the IO thread when the resource was not previously loaded into memory.
  • The asset_storage.request_asset() method may resume on the IO thread when the accessed asset resource was not previously loaded into memory.

When we are now loading an asset, we know that we might be on a different thread, so as long as we don't access anything outside of the coroutine function scope we are fine. However, depending on what we can do next we should switch the execution context for our coroutine.

If we got computation heavy things to do, we want to move the coroutine to a worker thread to not stall the game frame or the IO thread. If we want to use the loaded asset in-game we want to store it in some world trait, so we await an engine synchronization point. (like 'next_frame')

Because we are explicitly working with execution contexts, we don't need to explicitly use any mutexes or locks, we just move the coroutine to the location we want it to be, and we can access resources safely.

Each engine part has a specific location where it can be safely accessed, more on this system / module specific documentation.

Iceshards tasks API overview

Currently the task API is part of the core section and can be found under /source/code/core/tasks

A coroutine task is defined by returning a ice::Task<...> object which defines the result type in the template part. For example, ice::Task<> returns void, whereas ice::Task<ice::i32> returns a 32-bit integer value.

Additionally, one of the more common things that might be used are manual task synchronization points. This is your old mutex-like synchronization where the task will be executed and awaited to finish. (more on this later)

API Reference

To be added soon.