Affine Awaitables
Corosio uses the affine awaitable protocol from Boost.Capy to propagate executor affinity through coroutine chains. This ensures I/O completions resume your coroutine on the correct executor without manual dispatch.
The Problem
Consider a coroutine waiting for I/O:
capy::task<void> my_task(corosio::socket& s)
{
auto [ec, n] = co_await s.read_some(buf);
// Where does this line execute?
process(buf, n);
}
When the read completes, the I/O subsystem has data ready. But on which
thread should process() run? Without affinity tracking, completions
might arrive on arbitrary threads, forcing you to add synchronization
everywhere.
The Solution: Affine Awaitables
An affine awaitable receives the caller’s dispatcher through await_suspend:
template<capy::dispatcher Dispatcher>
auto await_suspend(
std::coroutine_handle<> h,
Dispatcher const& d) -> std::coroutine_handle<>
{
// Store d for later use when I/O completes
// Return noop_coroutine to suspend
}
The dispatcher d is typically an executor. When the I/O operation
completes, the awaitable resumes h through d:
// In I/O completion handler:
d(h); // Resume through the dispatcher
This guarantees the coroutine resumes where it expects to—on its associated executor.
How Affinity Propagates
When you launch a coroutine with async_run:
capy::async_run(ioc.get_executor())(my_task(socket));
The executor becomes the coroutine’s dispatcher. When my_task awaits
another coroutine or an I/O operation, it passes its dispatcher forward:
sequenceDiagram
participant Main
participant Task as my_task
participant Socket as s.read_some()
participant IOCP
Main->>Task: async_run(ex)(my_task)
Task->>Socket: co_await read_some()
Note over Socket: Stores ex as dispatcher
Socket->>IOCP: Submit read
Task-->>Main: Suspended
IOCP->>Socket: Read complete
Socket->>Task: ex(handle) - resume via dispatcher
Task->>Main: Completes
The Dispatcher Concept
A dispatcher is any callable that accepts a std::coroutine_handle<> and
returns a std::coroutine_handle<>:
template<typename T>
concept dispatcher = requires(T const& d, std::coroutine_handle<> h) {
{ d(h) } -> std::convertible_to<std::coroutine_handle<>>;
};
The returned handle enables symmetric transfer. If the dispatcher can
resume inline (same thread), it returns h. Otherwise, it posts h
for later execution and returns std::noop_coroutine().
Type-Erased Dispatchers
Corosio uses type-erased dispatchers (capy::any_dispatcher) internally.
This allows the socket implementation to store the dispatcher without
being templated on its type:
// Inside socket implementation:
capy::any_dispatcher dispatcher_;
void start_operation(std::coroutine_handle<> h, auto const& d)
{
dispatcher_ = d; // Type-erase
// ... start I/O ...
}
void complete_operation()
{
dispatcher_(handle_); // Resume through stored dispatcher
}
The type erasure cost is negligible compared to I/O latency.
Cancellation Support
Affine awaitables may also accept a std::stop_token for cancellation:
template<capy::dispatcher Dispatcher>
auto await_suspend(
std::coroutine_handle<> h,
Dispatcher const& d,
std::stop_token token) -> std::coroutine_handle<>
{
// Can check token.stop_requested() or register callback
}
If the stop token is triggered, the awaitable completes immediately with
errc::operation_canceled.
Writing Custom Affine Awaitables
To create your own affine awaitable, implement the extended await protocol:
struct my_awaitable
{
// Check if ready (can skip suspension)
bool await_ready() const noexcept { return false; }
// Standard result retrieval
int await_resume() const noexcept { return result_; }
// Affine await_suspend - receives dispatcher
template<capy::dispatcher Dispatcher>
auto await_suspend(
std::coroutine_handle<> h,
Dispatcher const& d) -> std::coroutine_handle<>
{
handle_ = h;
dispatcher_ = d;
start_async_work();
return std::noop_coroutine();
}
private:
void on_complete(int result)
{
result_ = result;
dispatcher_(handle_); // Resume via dispatcher
}
std::coroutine_handle<> handle_;
capy::any_dispatcher dispatcher_;
int result_;
};
Symmetric Transfer Optimization
When a coroutine completes and returns to a caller with the same dispatcher, symmetric transfer avoids going through the executor:
// In promise_type::final_suspend:
if (caller_dispatcher_ == my_dispatcher_)
return caller_handle_; // Direct transfer
else
return caller_dispatcher_(caller_handle_); // Go through dispatcher
This is why coroutine chains on the same executor are efficient—no queue operations between them.
Next Steps
-
I/O Context — The executor implementation
-
Buffer Sequences — Type-erased buffer interface