Custom Executor

Implementing the Executor concept with a single-threaded run loop.

What You Will Learn

  • Satisfying the Executor concept

  • Implementing execution_context, dispatch, and post

  • Running Capy coroutines on a custom scheduling system

Prerequisites

Source Code

#include <boost/capy.hpp>
#include <iostream>
#include <queue>
#include <thread>

using namespace boost::capy;

// A minimal single-threaded execution context.
// Demonstrates how to satisfy the Executor concept
// for any custom scheduling system.
class run_loop : public execution_context
{
    std::queue<std::coroutine_handle<>> queue_;
    std::thread::id owner_;

public:
    class executor_type;

    run_loop()
        : execution_context(this)
    {
    }

    ~run_loop()
    {
        shutdown();
        destroy();
    }

    run_loop(run_loop const&) = delete;
    run_loop& operator=(run_loop const&) = delete;

    // Drain the queue until empty
    void run()
    {
        owner_ = std::this_thread::get_id();
        while (!queue_.empty())
        {
            auto h = queue_.front();
            queue_.pop();
            h.resume();
        }
    }

    void enqueue(std::coroutine_handle<> h)
    {
        queue_.push(h);
    }

    bool is_running_on_this_thread() const noexcept
    {
        return std::this_thread::get_id() == owner_;
    }

    executor_type get_executor() noexcept;
};

class run_loop::executor_type
{
    friend class run_loop;
    run_loop* loop_ = nullptr;

    explicit executor_type(run_loop& loop) noexcept
        : loop_(&loop)
    {
    }

public:
    executor_type() = default;

    execution_context& context() const noexcept
    {
        return *loop_;
    }

    void on_work_started() const noexcept {}
    void on_work_finished() const noexcept {}

    std::coroutine_handle<> dispatch(
        std::coroutine_handle<> h) const
    {
        if (loop_->is_running_on_this_thread())
            return h;
        loop_->enqueue(h);
        return std::noop_coroutine();
    }

    void post(std::coroutine_handle<> h) const
    {
        loop_->enqueue(h);
    }

    bool operator==(executor_type const& other) const noexcept
    {
        return loop_ == other.loop_;
    }
};

inline
run_loop::executor_type
run_loop::get_executor() noexcept
{
    return executor_type{*this};
}

// Verify the concept is satisfied
static_assert(Executor<run_loop::executor_type>);

task<int> compute(int x)
{
    std::cout << "  computing " << x << " * " << x << "\n";
    co_return x * x;
}

task<> run_tasks()
{
    std::cout << "Launching 3 tasks with when_all...\n";

    auto [a, b, c] = co_await when_all(
        compute(3),
        compute(7),
        compute(11));

    std::cout << "\nResults: " << a << ", " << b << ", " << c
              << "\n";
    std::cout << "Sum of squares: " << a + b + c << "\n";
}

int main()
{
    run_loop loop;

    // Launch using run_async, just like with thread_pool
    run_async(loop.get_executor())(run_tasks());

    // Drive the loop -- all coroutines execute here
    std::cout << "Running event loop on main thread...\n";
    loop.run();

    std::cout << "Event loop finished.\n";
    return 0;
}

Build

add_executable(custom_executor custom_executor.cpp)
target_link_libraries(custom_executor PRIVATE Boost::capy)

Walkthrough

Inheriting execution_context

class run_loop : public execution_context
{
    // ...
    run_loop()
        : execution_context(this)
    {
    }
};

Custom execution contexts inherit from execution_context and pass this to the base constructor. The destructor must call shutdown() then destroy() to clean up coroutine state.

The Executor Concept

The nested executor_type must provide:

  • context() — returns a reference to the owning execution_context

  • on_work_started() / on_work_finished() — work-tracking hooks

  • dispatch(h) — resume immediately if already on this context, otherwise enqueue

  • post(h) — always enqueue for later execution

  • operator== — compare two executors for identity

static_assert(Executor<run_loop::executor_type>);

The static_assert verifies at compile time that all concept requirements are met.

Dispatch vs Post

std::coroutine_handle<> dispatch(
    std::coroutine_handle<> h) const
{
    if (loop_->is_running_on_this_thread())
        return h;          // resume inline
    loop_->enqueue(h);
    return std::noop_coroutine();  // defer
}

dispatch checks whether the caller is already running on the loop’s thread. If so, it returns the handle directly for inline resumption. Otherwise it enqueues and returns noop_coroutine so the caller continues without blocking.

post always enqueues, even if already on the right thread.

Driving the Loop

run_async(loop.get_executor())(run_tasks());
loop.run();

run_async enqueues the initial coroutine. loop.run() drains the queue, resuming coroutines one by one until all work completes. This is analogous to a GUI event loop or game tick loop.

Output

Running event loop on main thread...
Launching 3 tasks with when_all...
  computing 3 * 3
  computing 7 * 7
  computing 11 * 11

Results: 9, 49, 121
Sum of squares: 179
Event loop finished.

Exercises

  1. Add a stop() method that causes run() to exit early, even with work remaining

  2. Make the run loop thread-safe so work can be posted from other threads

  3. Integrate the run loop with a platform event system (e.g., epoll, kqueue, or a GUI framework)