Affine Awaitables
The affine awaitable protocol is a core concept in Corosio that enables automatic executor affinity propagation through coroutine chains. This page explains how it works and why it matters.
The Problem
When an I/O operation completes, some thread receives the completion notification. Without affinity tracking:
Thread 1: coroutine starts → co_await read() → suspends
Thread 2: (I/O completes) → coroutine resumes here (surprise!)
Your coroutine might resume on an arbitrary thread, forcing you to add synchronization everywhere.
The Solution: Executor Affinity
Affinity means a coroutine is bound to a specific executor. All resumptions occur through that executor:
Thread 1: coroutine starts → co_await read() → suspends
Thread 1: (executor dispatches) → coroutine resumes here (correct!)
When you launch a coroutine with run_async(ex), it has affinity to executor
ex. All its I/O operations capture ex and resume through it.
How Affinity Propagates
The affine awaitable protocol passes the executor through co_await:
capy::run_async(ex)(parent()); // parent has affinity to ex
task<void> parent()
{
co_await child(); // child inherits ex
}
task<void> child()
{
co_await sock.read_some(buf); // read captures ex, resumes through ex
}
Each co_await passes the current dispatcher to the awaited operation.
The Protocol in Detail
An affine awaitable provides special await_suspend overloads that receive
the dispatcher:
struct my_awaitable
{
bool await_ready() const noexcept;
Result await_resume() const noexcept;
// Standard form (for compatibility)
void await_suspend(std::coroutine_handle<> h);
// Affine form: receives dispatcher
template<capy::dispatcher Dispatcher>
auto await_suspend(
std::coroutine_handle<> h,
Dispatcher const& d) -> std::coroutine_handle<>;
// Affine form with stop token: receives dispatcher and cancellation
template<capy::dispatcher Dispatcher>
auto await_suspend(
std::coroutine_handle<> h,
Dispatcher const& d,
std::stop_token token) -> std::coroutine_handle<>;
};
The task’s await_transform selects the appropriate overload based on
what the awaitable supports.
Corosio Awaitables
All Corosio I/O operations return affine awaitables:
// socket::connect returns connect_awaitable
auto [ec] = co_await sock.connect(endpoint);
// socket::read_some returns read_some_awaitable
auto [ec, n] = co_await sock.read_some(buffer);
// timer::wait returns wait_awaitable
auto [ec] = co_await timer.wait();
Each stores the dispatcher provided during await_suspend and uses it
to resume the coroutine when the operation completes.
Dispatcher Type Erasure
Corosio uses capy::any_dispatcher for type erasure:
template<capy::dispatcher Dispatcher>
auto await_suspend(
std::coroutine_handle<> h,
Dispatcher const& d) -> std::coroutine_handle<>
{
// Store type-erased dispatcher
impl_->do_operation(h, capy::any_dispatcher(d), ...);
return std::noop_coroutine();
}
This allows the implementation to work with any executor type without templating everything.
Symmetric Transfer
When a child coroutine completes, it resumes its parent. If both have the same executor, symmetric transfer provides a direct tail call:
task<void> parent()
{
co_await child(); // child completes, transfers directly to parent
}
No executor involvement, no queuing—just a direct coroutine-to-coroutine transfer.
The mechanism:
-
Child’s final suspend awaitable returns parent’s handle
-
Compiler generates tail call to
coroutine_handle::resume() -
Parent resumes immediately on same thread
If executors differ, the child posts to the parent’s executor instead.
Cancellation Support
Affine awaitables can receive a stop token:
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()
// Can register for stop notification
}
Corosio operations check stop_requested() in await_ready() and during
the operation for prompt cancellation.
Flow Diagram Notation
To reason about affinity, use this compact notation:
| Symbol | Meaning |
|---|---|
|
Coroutines (lazy tasks) |
|
I/O operation |
|
|
|
Coroutine with explicit executor affinity |
|
Executors |
Implementing Affine Awaitables
To implement your own affine awaitable:
struct my_async_op
{
// Required members
operation_state& state_;
bool await_ready() const noexcept
{
return state_.is_complete();
}
Result await_resume() const noexcept
{
return state_.get_result();
}
// Affine suspend with dispatcher
template<capy::dispatcher Dispatcher>
auto await_suspend(
std::coroutine_handle<> h,
Dispatcher const& d) -> std::coroutine_handle<>
{
// Store h and d, start operation
state_.start(h, d);
return std::noop_coroutine();
}
// Affine suspend with dispatcher and stop token
template<capy::dispatcher Dispatcher>
auto await_suspend(
std::coroutine_handle<> h,
Dispatcher const& d,
std::stop_token token) -> std::coroutine_handle<>
{
state_.start(h, d, token);
return std::noop_coroutine();
}
};
When the operation completes, use the dispatcher to resume:
void complete()
{
dispatcher_(continuation_); // Resume through dispatcher
}
Legacy Awaitable Compatibility
Not all awaitables support the affine protocol. Capy’s task provides
automatic compatibility through await_transform:
-
If awaitable is affine: zero-overhead dispatch
-
If awaitable is standard: wrap in trampoline coroutine
The trampoline ensures correct affinity at the cost of one extra coroutine frame.
Summary
| Concept | Description |
|---|---|
Executor affinity |
Coroutine bound to specific executor |
Propagation |
Children inherit affinity via |
Affine protocol |
|
Symmetric transfer |
Zero-overhead resumption when executors match |
any_dispatcher |
Type-erased dispatcher for implementation |
Next Steps
-
I/O Context — The execution context
-
Error Handling — Cancellation patterns
-
Design Rationale — Why this design