Coroutines: An Overview

Foreword

  • A “function” that is not a function

  • Entered multiple times (what?)

    • ⟶ Suspended and resumed

  • “Stackless” (whatever that means)

  • Use case: Async

    • Looks like blocking, but isn’t

    • Event loop, but without callbacks

    • Multithreading replacement

    • Much like Python’s asyncio

    • Boost.Asio

  • Use case: generators (since C++23)

Prototypical Introductory Exampe: Fibonacci Numbers

  • Focus on usage

  • Coroutine definition

    Fibonacci fibonacci()
    {
        co_yield ...;
    }
    
  • Coroutine instantiation

    auto fibo = fibonacci();
    
  • Coroutine usage ⟶ Range-based-for on a generator?

Step By Step: Simplest

What I want is …

Coro hello()
{
    std::cout << "Hello" << std::endl;
    co_return;
}

int main()
{
    auto hello_instance = hello();
    return 0;
}

Simplest: Incremental Fixing And Explaining

  • promise_type: expected by the compiler

#include <coroutine>
struct Coro {
    struct promise_type
    {
        Coro get_return_object() { return {}; }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept(true) { return {}; }
        void return_void() {}
        void unhandled_exception() {}
    };
};
  • get_return_object(): inserted be compiler when coroutine is instantiated (hello())

  • initial_suspend(): don’t execute any code before somebody calls resume()

  • final_suspend(): don’t execute any code after falling off the end

  • void return_void(): apparently that is another customization point

  • void unhandled_exception(): ideally an exception should be propagated to the caller (we ignore it)

Try it out

  • Nothing happens ⟶ “Hello” not printed

  • Replace initial_suspend() return type to std::suspend_never

  • ⟶ “Hello” printed

  • Background: customization

    • <coroutine> is a set of building blocks

    • Async

    • Generators

Driving Coroutines: Coroutine Anatomy

auto hello_instance = hello();
  • Coroutine object created magically (Coro is only a wrapper type)

  • Must be stored somewhere

  • Associated with a promise_type object

  • Coroutine type: std::coroutine_handle<promise_type>

  • Retrieving the coroutine object by promise_type: std::coroutine_handle<promise_type>::from_promise(promise_type&)

  • Customization point: get_return_object()

Driving Coroutines: Resuming

  • Transform Coro into a class

  • Add Coro member: std::coroutine_handle<promise_type> _coro

  • Coro::resume()

  • Call in main()

  • ⟶ resumed until co_return

Download resume.cpp <intro/resume.cpp>
#include <coroutine>
#include <iostream>

class Coro {
public:
    struct promise_type
    {
        Coro get_return_object() { return Coro(this); }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept(true) { return {}; }
        void return_void() {}        
        void unhandled_exception() {}
    };
    using Handle = std::coroutine_handle<promise_type>;

public:
    Coro(promise_type* p) : _coro(Handle::from_promise(*p)) {}
    void resume() { _coro.resume(); }

private:
    Handle _coro;
};

Coro hello()
{
    std::cout << "Hello" << std::endl;
    co_return;
}

int main()
{
    auto hello_instance = hello();
    hello_instance.resume();
    return 0;
}

Suspension: Returning Control To Caller (co_yield)

  • co_yield: returns control to coroutine caller

  • .resume(): re-enters coroutine - this is the definition of coroutines

struct promise_type
{
    std::suspend_always yield_value(std::string);
}
Coro hello()
{
    std::cout << "Saying Hello" << std::endl;
    co_yield "Hello";
    std::cout << "Not Saying Bye" << std::endl;
    co_return;
}

int main()
{
    auto hello_instance = hello();
    hello_instance.resume();                           // <--- yields into promise
    auto value = hello_instance.last_value();          // <--- get yielded value from promise
    std::cout << "coro produced: " << value << std::endl;
    hello_instance.resume();                           // <--- terminate: resume until co_return
    return 0;
}
  • Customization point: yield_value()

  • co_yield parameter ideally stored in promise object

    • Made available though wrapper class Coro

Playing Around: Iteration, Mimicking Python Iterator Protocol

  • Coro::StopIteration

  • Coro::next()

  • Iteration …

    while (true) {
        try {
            std::cout << hello_instance.next() << std::endl;
        }
        catch (const Coro::StopIteration&) {
            break;
        }
    }
    

Playing Around: Iteration, Range-Based-For

  • Coro’s own iterator

    struct sentinel {};
    struct iterator
    {
        std::coroutine_handle<promise_type> coro;
        bool operator==(sentinel) const { return coro.done(); }
        iterator& operator++()
        {
            coro.resume();
            return *this;
        }
        std::string operator*() const
        {
            return coro.promise().last_value;
        }
    };
    
    iterator begin() const { return {std::coroutine_handle<promise_type>::from_promise(*_promise)}; }
    sentinel end() const { return {}; }
    
  • Iteration …

    for (auto elem: hello_instance)
        std::cout << elem << std::endl;
    
  • Bug fix: std::suspend_never() must have return type std::suspend_never!

Playing Around: Generic Generator

  • Future of C++: more tooling

  • Writing all that coroutine glue is not for beginners

  • Generator<T>

  • (Simply replace Coro with a template)

  • A future C++ version will deduce coroutine type to just that Generator<T> (or similar)

Playing Around: Fibonacci Numbers, Generator Version

  • Using Generator<T> for fibonacci coroutine (see beginning)

Pitfalls: Coroutines Are Stateful!

  • Debugging is close to impossible

  • ⟶ Get it right from the beginning

  • Pitfall (only one of many, I’m certain):

    Don’t access coroutine parameters by reference!

Broken version

Download cycle-broken.cpp <intro/cycle-broken.cpp>
#include "generator.h"
#include <iostream>
#include <vector>

Generator<int> cycle(const std::vector<int>& elems)
{
    while (true)
        for (const auto& e: elems)                     // <--- BOOM!!
            co_yield e;
}

int main()
{
    auto c = cycle({1,2,3,4});                         // <--- temporary
                                                       // <--- temporary gone here
    for (auto elem: c)                                 // <--- c still has reference to it
        std::cout << elem << std::endl;

    return 0;
}

Fixed version

(Move is ok too, clearly)

Download cycle-fixed.cpp <intro/cycle-fixed.cpp>
#include "generator.h"
#include <iostream>
#include <vector>

Generator<int> cycle(const std::vector<int> elems)     // <--- BY COPY!!
{
    while (true)
        for (const auto& e: elems)
            co_yield e;
}

int main()
{
    auto c = cycle({1,2,3,4});

    for (auto elem: c)
        std::cout << elem << std::endl;

    return 0;
}