Starting Coroutines
The Execution Model
Capy tasks are lazy—they do not execute until something drives them. Two mechanisms exist:
-
Awaiting — One coroutine awaits another (
co_await task) -
Starting — Non-coroutine code initiates execution (
run_async)
When a task is awaited, the awaiting coroutine provides context: an executor for dispatching completion and a stop token for cancellation. But what about the first task in a chain? Non-coroutine code must start that task explicitly.
run_async: Entry from Non-Coroutine Code
run_async is the bridge between regular code and coroutine code. It takes an executor, creates the necessary context, and starts the task executing.
#include <boost/capy.hpp>
using namespace boost::capy;
task<int> compute()
{
co_return 42;
}
int main()
{
thread_pool pool;
run_async(pool.get_executor())(compute());
// Task is now running on the thread pool
pool.join(); // wait for outstanding work to complete
}
Two-Call Syntax
Notice the unusual syntax: run_async(executor)(task). This is intentional and relates to C++17 evaluation order.
C++17 guarantees that in the expression f(a)(b):
-
f(a)is evaluated first, producing a callable -
bis evaluated second -
The callable is invoked with
b
This ordering matters because the task’s coroutine frame is allocated during step 2, and run_async sets up thread-local allocator state in step 1. The task inherits that allocator.
|
Construct the task as the direct argument of the two-call expression. Three patterns split the two calls apart, and only the first is caught by the compiler. Stored wrapper. Storing the result of
Preconstructed task. Storing the task in a local and passing it in afterwards compiles and runs. Its frame is allocated by the time the wrapper exists, so it never sees the wrapper’s allocator. A moved-from local, or a task returned by an earlier statement, behaves the same way. Wrapper function. A helper that accepts a task and performs the two-call pattern internally has the same effect. Its caller constructs the task as an argument to the helper, which is before the helper’s body runs. The two silent patterns produce no diagnostic at all: the task runs, on a
coroutine frame that came from the wrong allocator.
Always use the two-call pattern in a single expression. |
Handler Overloads
run_async accepts optional handlers for results and exceptions:
// Result handler only (an unhandled exception calls std::terminate)
run_async(ex, [](int result) {
std::cout << "Got: " << result << "\n";
})(compute());
// Separate handlers for result and exception
run_async(ex,
[](int result) { std::cout << "Result: " << result << "\n"; },
[](std::exception_ptr ep) {
try { std::rethrow_exception(ep); }
catch (std::exception const& e) {
std::cout << "Error: " << e.what() << "\n";
}
}
)(compute());
When no result handler is provided, the result is discarded. An exception
that goes unhandled (no error handler was supplied, or a handler let one
escape) calls std::terminate. To react to an error, pass an error handler;
it receives the std::exception_ptr and should handle it in place rather
than rethrowing. To catch an error, co_await the work inside a coroutine
and use try/catch rather than starting it fire-and-forget.
run: Executor Hopping Within Coroutines
Inside a coroutine, use run to execute a child task on a different executor:
task<int> compute_on_pool(thread_pool& pool)
{
// This task runs on whatever executor we're already on
// But this child task runs on the pool's executor:
int result = co_await run(pool.get_executor())(expensive_computation());
// After co_await, we're back on our original executor
co_return result;
}
Executor Affinity
By default, a task inherits its caller’s executor. This means completions are dispatched through that executor, ensuring thread affinity for thread-sensitive code.
run overrides this inheritance for a specific child task, binding it to a different executor. The child task runs on the specified executor, and when it completes, the parent task resumes on its original executor.
This pattern is useful for:
-
Running CPU-intensive work on a thread pool
-
Performing I/O on an I/O-specific context
-
Ensuring UI updates happen on the UI thread
Stop Token Propagation
Both run_async and run propagate stop tokens to the task they start and all tasks it awaits. The task accesses its token via co_await this_coro::stop_token.
Injecting a Token with run_async
Since run_async is called from non-coroutine code, there is no caller token to inherit. Pass a stop token explicitly:
std::stop_source source;
run_async(ex, source.get_token())(cancellable_task());
// Later, to request cancellation:
source.request_stop();
Inheritance with run
run is called from within a coroutine, so it inherits the caller’s stop token by default:
task<void> parent()
{
// Child automatically receives our stop token
co_await run(pool.get_executor())(child_task());
}
To override with a different token, pass it explicitly:
task<void> parent()
{
std::stop_source local;
// Child gets local's token, not our caller's
co_await run(pool.get_executor(), local.get_token())(child_task());
}
Handler Threading
Handlers passed to run_async are invoked on whatever thread the executor schedules:
// If pool has 4 threads, the handler runs on one of those threads
run_async(pool.get_executor(), [](int result) {
// This runs on a pool thread, NOT the main thread
update_shared_state(result);
})(compute());
If you need results on a specific thread, use appropriate synchronization or dispatch mechanisms.