home

C++20 coroutines explained simply

May 12nd, 2022
8 minute read

C++20 brings coroutines to the language, and, like most things in C++, they are almost unnecessarily powerful and difficult to understand. C++20 also brings no standard coroutine-helper library, which means we have to write all the scheduling/handling code ourselves. Luckily, this gives us a great opportunity to understand how they work, so that when the libraries do come, we don’t just view them as compiler black magic.

What is a coroutine?

A coroutine is a function that can suspend its execution, return to the caller, and then later be resumed. We can use them to implement async/await, generators, and other useful patterns. In C++, we can declare coroutine by writing a function with the co_await, co_yield, or co_return keywords anywhere in its body. For example, before even getting into implementation, let’s look at a coroutine we may want to write:

Task my_coroutine() {
    int result = co_await my_async_function();
    co_yield result * 2;
    co_return result + 5;
}

int main() {
    Task task = my_coroutine();

    app.go_do_other_work();

    task.wait_until_ready();
    std::cout << task.get_yielded_value() << std::endl;
    task.resume();
    std::cout << task.get_returned_value() << std::endl;
}

What will we expect this coroutine to do? When we call my_coroutine(), it should ideally do the following:

  1. Go call my_async_function, which in theory does some asynchronous work like reading a file. The coroutine is paused while the asynchronous function waits and the calling code goes and does some other work.
  2. When my_async_function is ready, the coroutine will be resumed, yields some computation to be read from task.get_yielded_value(), and then pauses itself.
  3. When the task is later resumed, it will return some other computation, ready to be read by task.get_returned_value().

Cool! Unfortunately, C++20 comes with no standard coroutine library so we’ll need to implement all that functionality ourselves.

Basic coroutines

A coroutine has a few important, distinct objects associated with it:

Let’s continue with the my_coroutine example created above. When the coroutine is created, by calling my_coroutine(), the compiler will do the following:

  1. Construct the promise type, as defined by Task::promise_type
  2. Create the return object using promise.get_return_object()
  3. co_await’s the Awaitable returned by promise.initial_suspend()
  4. Execute!
  5. co_await’s the Awaitable returned by promise.final_suspend()
  6. Destructs the promise.

So already we have a few objects we need to define:

struct Task {
    struct promise_type {
        Task get_return_object() { return {}; }
        void unhandled_exception() {} // called when we have an unhandled exception
        MyAwaiter initial_suspend() // TODO
        MyAwaiter final_suspend() noexcept // TODO
    }
};

What exactly is an Awaitable then? Essentially it is something that a coroutine can wait for; a socket with data to read, a file handle waiting for data to write, etc. But for the compiler, all that matters is that it is something that can be passed into co_await. When given something to co_await, the coroutine will:

  1. Check the boolean returned by awaiter.await_ready(); if true (the awaiter is ready), we don’t wait, and skip step 2.
  2. Suspend the coroutine and call awaiter.await_suspend(handle). It is now the awaiter’s job to resume the coroutine using the handle, whenever the socket is ready or whatever. Ideally we would pass the handle to some kind of scheduling manager, which would resume the handle whenever the coroutine should be resumed.
  3. Call awaiter.await_resume() and gives its return value back to the coroutine as the value of the co_await expression.

Given these requirements, let’s write an awaiter to return from the initial_suspend function. Remember this will be awaited when the coroutine begins, but we don’t need to wait on anything, so let’s write an awaiter that is always ready.

struct MyAwaiter {
    bool await_ready() { return true; }
    void await_suspend(std::coroutine_handle<> handle) {}
    void await_resume() {}
};

This never-suspending awaiter is so common it comes pre-defined as std::suspend_never. From now on we’ll use the std class, but it’s nice to know there’s no std magic happening inside. There’s also std::suspend_always, which always suspends the coroutine, forcing it to be manually resumed later.

To write my_async_function, we could do some real asynchronous work, but for simplicity let’s just make it immediately yield with the value 3.

struct Awaiter2 {
    bool await_ready() { return true; }
    void await_suspend(std::coroutine_handle<> handle) {}
    int await_resume() { return 3; }
};

Awaiter2 my_async_function() { return {}; }

Now we’re almost done, but if we try and compile now we get a few errors:

error: no member named 'yield_value' in 'promise_type'
    co_yield result * 2;
    ^~~~~~~~
error: no member named 'return_value' in 'promise_type'
    co_return result + 5;
    ^~~~~~~~~

Of course, we haven’t yet defined the behaviour when yielding or returning. The line co_yield result * 2 is actually equivalent to co_await promise.yield_value(result * 2). Typically we would store the yield value, then return a std::suspend_always to immediately suspend the coroutine. Let’s store the yield value directly in the promise_type struct.

    struct promise_type {
        int value;
        ...
        std::suspend_always yield_value(int val) { value = val; return {}; }
    };

Finally, we need a return_value function, which is called whenever we co_return a value from the coroutine. We’ll just store the value again.

    struct promise_type {
        ...
        void return_value(int value) { value = val; }
    };

Now our code compiles! It won’t do anything yet, so let’s write a main to call this coroutine:

int main() {
    Task task = my_coroutine(); // runs until the first suspension point; the co_yield
    std::cout << task.value() << std::endl; // prints the value from the co_yield, aka 6
    task.resume(); // the co_yield just suspended the coroutine
                   // resume it to run until the next suspension point
    std::cout << task.value() << std::endl; // prints the value from the co_return, aka 8
}

We now need to write this interface in our Task class. First, we’ll store an std::coroutine_handle, which will allow us to resume the coroutine and access the Promise type.

struct Task {
    ...
    std::coroutine_handle<promise_type> handle;
};

Now when we construct the Task we must give it the handle:

    struct promise_type {
        ...
        Task get_return_object() {
            return { std::coroutine_handle::from_promise<promise_type>(*this) };
        }
        ...
    };

Now our .value() and .resume() functions are easy to write, because they essentially just wrap functionality the std::coroutine_handle gives us.

struct Task {
    ...

    int value() {
        return handle.promise().value;
    }

    void resume() {
        handle.resume();
    }
};

Try the code here

A simple generator iterator

Anyone who writes Python knows it has a very easy way of writing iterators using generators, which allow writing suspendable and resumable code in a straight-line manner:

def get_squares(max):
    i = 0
    while i < max:
        yield i*i
        i += i

def main():
    for square in get_squares(5):
        print(square)

Anyone who writes C++ knows writing iterators is complicated and annoying, so what if we used coroutines to allow us to write code like above?

Generator get_squares(int max) {
    for (int i = 0; i < max; i++) {
        co_yield i*i;
    }
}

int main() {
    for (int square : get_squares(5)) {
        std::cout << square << std::endl;
    }
}

This is not only possible, it’s quite easy if you read through the explanation above! Let’s start by defining our return type and promise type.

struct Generator {
    struct promise_type {
        int value;
        bool finished;

        Generator get_return_object() {
            return { std::coroutine_handle<promise_type>::from_promise(*this) };
        }

        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void unhandled_exception() {}

        std::suspend_always yield_value(int val) { value = val; return {}; }
        void return_void() { finished = true; }
    };
    
    std::coroutine_handle<promise_type> handle;
    ...

Just like above, we store the handle in the return type, and we store any yielded values in the promise type. Here we also store a boolean indicating if the coroutine is finished. Next, let’s define our iterator.

struct Generator {
    ...
    
    struct Sentinel {};
    struct Iterator {
        Generator &g;

        int operator*() const {
            return g.handle.promise().value;
        }

        Iterator& operator++() {
            g.handle.resume();
            return *this;
        }

        friend bool operator==(const Iterator &it, Sentinel) {
            return it.g.handle.promise().finished;
        }

        friend bool operator!=(const Iterator &it, Sentinel) {
            return !it.g.handle.promise().finished;
        }
    };
    ...

The dereference operation just returns the last value yielded from the coroutine, and the ++ operator resumes the coroutine. The == and != operators check if the iterator is at the end by checking the .finished flag in the promise.

Finally, we can write our .begin() and .end() to make Generator iterable:

struct Generator {
    ...
    
    Iterator begin() { return { *this }; }
    Sentinel end() { return {}; }
};

And that’s it! Python-style generators in about 50 lines of code.

Try the code here