I/O Awaitables and Execution
Modern C++ coroutines let you write asynchronous code that reads like synchronous code. But a coroutine alone doesn’t know where to run or how to be cancelled. This tutorial teaches you the execution model that answers both questions: execution contexts provide the "where," executors provide the "how," and awaitables tie them together with cancellation support.
By the end, you’ll understand how to write coroutines that are efficient, cancellable, and composable—running on any execution context you choose.
The Execution Model
Where Work Runs: Execution Contexts
An execution context is a place where work happens. Think of it as a workplace with resources—threads, event loops, or completion ports. Different contexts serve different purposes:
-
A thread pool distributes work across multiple threads for parallelism
-
An I/O context runs an event loop for network and file operations
-
A system context provides a process-wide default
thread_pool pool(4); // A context with 4 worker threads
The execution context owns these resources. When it’s destroyed, pending work is cancelled and resources are released. This ownership is fundamental: the context defines the lifetime of all work submitted to it.
Lightweight Handles: Executors
An executor is a lightweight handle to an execution context. It doesn’t own resources—it just knows how to submit work. You can copy executors freely; they’re cheap to pass around.
auto ex = pool.get_executor(); // Get an executor handle
ex.post(some_coroutine); // Submit work to the pool
Every executor provides two key operations:
-
post(h)— Queue a coroutine for later execution -
dispatch(h)— Execute immediately if safe, otherwise queue
The difference matters for performance. When you’re already running on the right thread, dispatch avoids the overhead of queuing. When you’re not, it queues like post.
The Contract: Executor Concept
Any type satisfying the Executor concept works throughout the library. The requirements are practical:
template<class E>
concept Executor =
std::is_nothrow_copy_constructible_v<E> &&
requires(E const& e, std::coroutine_handle<> h) {
{ e.context() } noexcept; // Get the owning context
{ e.on_work_started() } noexcept; // Track outstanding work
{ e.on_work_finished() } noexcept; // Track completion
{ e.dispatch(h) }; // Execute or queue
{ e.post(h) }; // Always queue
{ e == e } noexcept -> std::convertible_to<bool>;
};
The work tracking operations—on_work_started and on_work_finished—enable graceful shutdown. A context knows when all work is done because these calls are paired.
I/O Awaitables
Beyond Standard Awaitables
A standard C++20 awaitable has a simple await_suspend:
void await_suspend(std::coroutine_handle<> h);
It receives the coroutine handle and… that’s it. No information about where to resume, no way to cancel.
An I/O awaitable extends this with scheduler affinity and cancellation:
coro await_suspend(coro h, executor_ref ex, std::stop_token token);
The executor tells the awaitable where to resume. The stop token enables cancellation. This signature distinguishes I/O operations from simple synchronization primitives.
The IoAwaitable Concept
template<typename A>
concept IoAwaitable =
requires(A a, coro h, executor_ref ex, std::stop_token token) {
a.await_suspend(h, ex, token);
};
Every async I/O operation in this library returns an IoAwaitable. When you co_await it, the coroutine machinery passes your executor and stop token automatically.
Cancellation in Practice
Here’s how an I/O awaitable uses the stop token:
struct my_io_op
{
bool await_ready() const noexcept { return false; }
coro await_suspend(coro h, executor_ref ex, std::stop_token token)
{
// Start the async operation
start_async_io([h, ex, token]() {
if (token.stop_requested())
{
// Don't complete normally—signal cancellation
complete_with_cancellation();
}
else
{
// Resume the coroutine on the right executor
ex.dispatch(h);
}
});
return std::noop_coroutine();
}
void await_resume() {}
};
The pattern is consistent: check the stop token, complete appropriately, and resume through the executor. This ensures your coroutine always resumes where it should.
Tasks: The Primary Building Block
Lazy Evaluation
A task<T> is a lazy coroutine that doesn’t start until awaited. This is crucial for composition:
task<int> compute()
{
co_return 42; // Nothing runs yet
}
task<> caller()
{
int value = co_await compute(); // NOW it runs
}
Lazy evaluation means you can build a complex chain of operations without any executing until you want them to.
Automatic Context Propagation
When you await a task, it inherits your executor and stop token:
task<> parent()
{
auto token = co_await this_coro::stop_token; // Get my token
auto ex = co_await this_coro::executor; // Get my executor
co_await child_task(); // child inherits both
}
The this_coro namespace provides tag objects that yield coroutine context without suspending. Inside your coroutine, you can always discover who you are and whether someone wants you to stop.
The task<T> Type
task<int> fetch_data()
{
auto [ec, n] = co_await stream.read_some(buffer);
if (ec.failed())
co_return 0;
co_return process(buffer, n);
}
task<> run_session()
{
int data = co_await fetch_data();
// ...
}
Tasks propagate exceptions automatically. If fetch_data throws, run_session receives the exception when it awaits. No manual error checking for exceptions—just handle them at the appropriate level.
Results: Error Handling Made Ergonomic
The io_result Type
Most I/O operations can fail, and you need to know both whether they failed and what they produced. The io_result<Ts…> type bundles an error code with additional values:
auto [ec, n] = co_await stream.read_some(buffer);
if (ec.failed())
co_return;
// Use n bytes from buffer
Structured bindings make this natural. The error code is always first, followed by operation-specific values.
Result Variants
Different operations return different results:
io_result<> // Just success/failure (connect, shutdown)
io_result<std::size_t> // Bytes transferred (read, write)
io_result<int, double> // Multiple values if needed
The io_task<Ts…> alias combines task with io_result:
io_task<> connect_to_server(socket& s, endpoint ep)
{
co_return co_await s.connect(ep);
}
io_task<std::size_t> transfer_data(socket& s, buffer buf)
{
co_return co_await s.write(buf);
}
Immediate Results
Sometimes you have a synchronous operation that needs to satisfy an async interface. The immediate<T> wrapper never suspends:
immediate<io_result<std::size_t>>
write(const_buffer buf)
{
auto n = write_sync(buf); // Synchronous
return ready(n); // Wrap as awaitable
}
The ready() helper creates immediate awaitables with the right structure:
ready() // Successful io_result<>
ready(n) // Successful io_result<std::size_t> with value n
ready(ec) // Failed io_result<> with error ec
ready(ec, n) // io_result<std::size_t> with error and value
Launching Tasks
From Regular Code: run_async
You can’t co_await from main(). You need a bridge from regular code to coroutine land:
int main()
{
thread_pool pool(4);
auto ex = pool.get_executor();
run_async(ex)(my_task()); // Launch and forget
pool.join(); // Wait for work to complete
}
The run_async function takes an executor and returns a launcher. Call the launcher with your task to start it running.
Handling Results
You probably want to know when the task finishes and what it produced:
run_async(ex,
[](int result) { std::cout << "Got: " << result << "\n"; },
[](std::exception_ptr ep) { handle_error(ep); }
)(compute_value());
The second and third arguments are handlers for success and failure. If you provide only one handler that accepts both signatures, it handles both cases.
With Cancellation
Pass a stop token to enable cancellation:
std::stop_source source;
run_async(ex, source.get_token())(cancellable_task());
// Later, from another thread:
source.request_stop();
The stop token propagates through the entire coroutine chain. Every co_await in every nested task can check if cancellation was requested.
From Other Coroutines: run
Inside a coroutine, use run to execute a task with different parameters:
task<> outer()
{
// Run on a different executor
co_await run(other_executor)(inner_task());
// Run with a different stop token
std::stop_source source;
co_await run(source.get_token())(cancellable());
// Combine both
co_await run(other_ex, source.get_token())(my_task());
}
Without run, tasks inherit the caller’s executor and stop token. With run, you override either or both.
Type Erasure: Flexibility at Runtime
executor_ref: Zero-Cost Abstraction
Sometimes you need to store an executor without knowing its concrete type. The executor_ref class provides type erasure with no allocation:
void store_executor(executor_ref ex)
{
if (ex)
saved_executor_ = ex; // Just copies pointers
}
It uses a vtable pointer and a pointer to the original executor. No heap allocation, no virtual function calls per operation beyond the initial vtable lookup.
Important: executor_ref has reference semantics. The original executor must outlive all references to it.
any_executor: Ownership with Erasure
When you need to store an executor with ownership, use any_executor:
any_executor exec = pool.get_executor(); // Owns a copy
// exec remains valid even if pool is destroyed... but don't do that
This uses a shared pointer internally, so copies are cheap and share the underlying executor.
Thread Pools and Contexts
The thread_pool Class
A thread pool manages a fixed set of worker threads:
thread_pool pool(4); // 4 workers
auto ex = pool.get_executor();
run_async(ex)(my_work());
run_async(ex)(other_work());
// Work runs on pool threads
// Destructor waits for all work to complete
Pass 0 for automatic thread count based on hardware concurrency. Pass a string as the second argument to name threads for debugging:
thread_pool pool(0, "io-"); // Threads named io-0, io-1, ...
Serialization with Strands
The Problem: Shared State
When multiple coroutines access shared state, you need synchronization. Traditional mutexes block threads. Strands provide a better way for coroutines.
The Solution: strand<Ex>
A strand wraps an executor and guarantees that coroutines dispatched through it never run concurrently:
thread_pool pool(4);
strand s(pool.get_executor());
// These never run at the same time
s.post(coroutine1);
s.post(coroutine2);
s.post(coroutine3);
Strands are lightweight handles. Copies share serialization state:
strand s1(ex);
strand s2 = s1; // Same strand!
s1.post(coro1);
s2.post(coro2); // Serialized with coro1
Coroutine Synchronization Primitives
async_event: Waiting for Conditions
When one coroutine needs to signal others:
async_event ready;
task<> waiter()
{
co_await ready.wait();
// Event was set!
}
task<> signaler()
{
// Do preparation...
ready.set(); // Wake all waiters
}
Multiple coroutines can wait on the same event. When set() is called, all waiters resume. After set(), new waiters return immediately until clear() is called.
coro_lock: Mutual Exclusion
For critical sections that span co_await:
coro_lock mutex;
task<> protected_operation()
{
co_await mutex.lock();
// Critical section - no other coroutine can enter
co_await do_something_async(); // Still protected!
mutex.unlock();
}
Or with RAII:
task<> protected_operation()
{
auto guard = co_await mutex.scoped_lock();
// Protected until guard destructs
co_await do_something_async();
} // Unlocks here
These primitives are for single-threaded use where multiple coroutines contend. They don’t block threads—coroutines suspend and resume.
Memory Management
Frame Allocation
Every coroutine has a frame—memory holding its local variables and state. By default, frames come from the heap. For performance-critical code, you can customize this.
Building Custom Awaitables
The io_awaitable_support Mixin
To create a coroutine type that works with I/O awaitables, inherit from io_awaitable_support:
struct my_task
{
struct promise_type : io_awaitable_support<promise_type>
{
my_task get_return_object();
auto initial_suspend() noexcept { return std::suspend_always{}; }
auto final_suspend() noexcept;
void return_void();
void unhandled_exception();
};
// Awaitable interface...
};
The mixin provides:
-
Frame allocation through the custom allocator
-
Stop token storage and access
-
Executor storage and access
-
await_transformthat interceptsthis_coro::stop_tokenandthis_coro::executor
Accessing Coroutine Context
Inside your coroutine, access the environment:
task<> example()
{
auto token = co_await this_coro::stop_token;
if (token.stop_requested())
co_return;
auto ex = co_await this_coro::executor;
// Use ex for something...
}
These co_await expressions never suspend—they return immediately with the stored value.
Concept Constraints
decomposes_to: Structured Binding Support
Constrain functions to accept only types that decompose to specific types:
template<typename T>
requires decomposes_to<T, std::error_code, std::size_t>
void process(T result)
{
auto [ec, n] = result;
// ...
}
This works with aggregates and tuple-like types.
awaitable_decomposes_to: Async Constraints
Constrain awaitables by their result type:
template<typename A>
requires awaitable_decomposes_to<A, std::error_code, std::size_t>
task<> process(A&& op)
{
auto [ec, n] = co_await std::forward<A>(op);
if (ec.failed())
co_return;
// Process n bytes
}
This ensures the awaitable produces the expected result structure.
Keeping Contexts Alive
executor_work_guard
Sometimes you need a context to stay alive even when there’s no pending work:
{
thread_pool pool(4);
auto guard = make_work_guard(pool.get_executor());
std::thread t([&pool] { pool.run(); });
// ... set things up, post work ...
guard.reset(); // Now pool can finish
t.join();
}
The guard calls on_work_started() on construction and on_work_finished() on destruction. This keeps the context alive until you’re ready.
Summary
The execution model connects these pieces:
-
Execution contexts own resources where work runs
-
Executors are lightweight handles for submitting work
-
I/O awaitables receive executor and stop token for proper resumption and cancellation
-
Tasks compose awaitables with automatic context propagation
-
Launch functions bridge regular code and coroutines
-
Strands serialize access to shared state
-
Synchronization primitives coordinate coroutines without blocking threads
Everything flows from the fundamental insight: coroutines need to know where to resume and whether to stop. The await_suspend(h, ex, token) signature captures this, and the rest follows naturally.
Start simple—a thread pool, an executor, and run_async. As your needs grow, add strands for serialization, custom allocators for performance, and stop tokens for cancellation. The pieces compose cleanly because they share a common protocol.
Your coroutines are now first-class citizens of a concurrent world, with the elegance of sequential code and the power of asynchronous execution.