Executors and Execution Contexts
This section explains executors and execution contexts—the mechanisms that control where and how coroutines execute.
The Same-Executor Invariant
Capy enforces one rule above all others:
A coroutine always resumes on the executor it was started with.
This rule is what keeps shared state safe by default. Consider a connection handler started on a strand:
task<void> handle_client(connection& conn)
{
auto req = co_await conn.read();
auto resp = process(req);
co_await conn.write(resp);
conn.stats.requests++;
}
Start this on a strand, and every resumption—after conn.read() and after conn.write()—happens on that strand. The update to conn.stats.requests is therefore free of data races without any mutex.
Without the invariant, the coroutine could resume after co_await conn.read() on an io_uring completion thread, a pool thread, or wherever the I/O subsystem completed the operation. Correct code would then need either a mutex around every access to shared state or an explicit resume-on-this-strand step after every co_await. A mutex defeats the purpose of the strand, and a single forgotten resume step reintroduces the data race.
Because the invariant holds, the safe behavior is automatic and the unsafe behavior does not compile. Awaiting a plain awaitable—one that could resume the coroutine on any thread—is rejected. See Bridging a Foreign Awaitable for why, and for the explicit escape hatch.
How the Invariant Is Maintained
Affinity propagates forward. Starting a task with run_async(ex) binds it to ex. A child co_await-ed from that task inherits the same executor automatically, and so on down the chain. When a child completes, control returns to its caller through the caller’s executor. When both share the same executor—the common case—that return is a direct symmetric transfer with no queuing. Only a deliberate executor change requires a dispatch.
That deliberate change is what run provides. It runs a subtree on a different executor and restores the caller’s executor when the subtree completes (see Starting Coroutines). This is also why I/O objects with executor-bound invariants—a socket tied to one io_context, a Windows IOCP handle, executor-specific timer state—remain safe. A coroutine holding them never resumes on the wrong executor mid-body.
The Executor Concept
An executor is an object that can schedule work for execution. See
Executor for the exact syntactic and semantic requirements, and
Executor Concept Design for the
rationale behind them.
dispatch() vs post()
Both methods schedule a coroutine for execution, but with different semantics:
dispatch(c)-
May execute inline if the current thread is already associated with the executor. Returns a coroutine handle—either
c.hfor inline resumption via symmetric transfer, orstd::noop_coroutine()if the work was queued. This enables symmetric transfer optimization. post(c)-
Always queues the continuation for later execution. Never executes inline. Returns void. Use when you need guaranteed asynchrony.
executor_ref: Type-Erased Executor
executor_ref wraps any executor in a type-erased container, allowing code to work with executors without knowing their concrete type:
void schedule_work(executor_ref ex, continuation& c)
{
ex.post(c); // Works with any executor
}
int main()
{
thread_pool pool;
auto pool_ex = pool.get_executor();
executor_ref ex = pool_ex; // Type erasure; pool_ex must outlive ex
continuation& c = make_suspended_work(pool); // a coroutine parked on the pool
schedule_work(ex, c);
pool.join();
}
Here make_suspended_work starts a coroutine on the pool and returns the continuation it parked; posting that continuation through the type-erased ex resumes it.
executor_ref stores a reference to the underlying executor—the original executor must outlive the executor_ref.
thread_pool: Multi-Threaded Execution
thread_pool manages a pool of worker threads that execute coroutines concurrently:
#include <boost/capy/ex/thread_pool.hpp>
int main()
{
// Create pool with 4 threads
thread_pool pool(4);
// Get an executor for this pool
auto ex = pool.get_executor();
// Start work on the pool
run_async(ex)(my_task());
pool.join(); // wait for outstanding work to complete
}
Constructor Parameters
See thread_pool for the exact constructor signature.
-
num_threads— Number of worker threads. If 0, uses hardware concurrency. -
thread_name_prefix— Prefix for thread names (useful for debugging).
Thread Safety
Work posted to a thread_pool may execute on any of its worker threads. If your coroutines access shared data, you must use appropriate synchronization.
execution_context: Base Class
execution_context is the base class for execution contexts. It provides:
-
Frame allocator access via
get_frame_allocator() -
Service infrastructure for extensibility
Custom execution contexts inherit from execution_context:
class my_context : public execution_context
{
public:
// ... custom implementation
my_executor get_executor();
};
strand: Serialization Without Mutexes
A strand ensures that handlers are executed in order, with no two handlers executing concurrently. This eliminates the need for mutexes when all access to shared data goes through the strand.
#include <boost/capy/ex/strand.hpp>
class shared_resource
{
strand<thread_pool::executor_type> strand_;
int counter_ = 0;
public:
explicit shared_resource(thread_pool& pool)
: strand_(pool.get_executor())
{
}
task<int> increment()
{
// All increments are serialized through the strand
co_return co_await run(strand_)(do_increment());
}
private:
task<int> do_increment()
{
// No mutex needed—strand ensures exclusive access
++counter_;
co_return counter_;
}
};
How Strands Work
The strand maintains a queue of pending work. When work is dispatched:
-
If the calling thread is already executing within the strand, the new work runs immediately, inline
-
Otherwise, the new work is queued, even if the strand is idle
-
When the current work completes, the next queued item runs
This provides logical single-threading without blocking physical threads.
Single-Threaded vs Multi-Threaded Patterns
Single-Threaded
For single-threaded applications, use a context with one thread:
thread_pool single_thread(1);
auto ex = single_thread.get_executor();
// All work runs on the single thread