Executors and Execution Contexts
This section explains executors and execution contexts—the mechanisms that control where and how coroutines execute.
Prerequisites
-
Completed Launching Coroutines
-
Understanding of
run_asyncandrun
The Same-Executor Invariant
Capy enforces one rule above all others:
A coroutine always resumes on the executor it was launched with.
This rule is what keeps shared state safe by default. Consider a connection handler launched 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++;
}
Launch 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. Launching 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 Launching 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. An executor must be nothrow copy- and move-constructible and provide the following interface:
concept Executor = requires(E const& ce, E const& ce2, continuation& c) {
// Equality comparable
{ ce == ce2 } noexcept -> std::convertible_to<bool>;
// Owning context, returned as an lvalue reference to a type
// derived from execution_context
{ ce.context() } noexcept;
// Work tracking
{ ce.on_work_started() } noexcept;
{ ce.on_work_finished() } noexcept;
// Scheduling
{ ce.dispatch(c) } -> std::same_as<std::coroutine_handle<>>;
{ ce.post(c) };
};
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 = /* ... */;
schedule_work(ex, c);
}
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();
// Launch work on the pool
run_async(ex)(my_task());
pool.join(); // wait for outstanding work to complete
}
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 no other work is executing on the strand, the new work runs immediately
-
If other work is executing, the new work is queued
-
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
Reference
| Header | Description |
|---|---|
|
The Executor concept definition |
|
Type-erased executor wrapper |
|
Multi-threaded execution context |
|
Base class for execution contexts |
|
Serialization primitive |
You have now learned about executors, execution contexts, thread pools, and strands. In the next section, you will learn about the IoAwaitable protocol that enables context propagation.