How to Use asio::spawn for Stackful Coroutines: A Complete Guide
asio::spawn creates stackful coroutines that use basic_yield_context as a completion token, allowing asynchronous operations to suspend and resume execution while maintaining synchronous-looking code flow.
The Asio library provides asio::spawn as the primary mechanism for writing stackful coroutines in C++. This function, implemented in include/asio/spawn.hpp, enables developers to write asynchronous code that appears sequential while integrating seamlessly with Asio's executor model.
Understanding asio::spawn Architecture
The implementation of asio::spawn rests on three core components that manage coroutine lifecycle and execution context.
The spawned_thread_base Abstraction
At the heart of every spawned coroutine lies spawned_thread_base, defined in include/asio/spawn.hpp lines 35-50. This abstract base class handles suspension and resumption, maintains cancellation state, and manages ownership of the coroutine's execution thread. It provides the low-level machinery that the coroutine driver uses to switch contexts when asynchronous operations complete.
basic_yield_context as a Completion Token
The basic_yield_context<Executor> serves as the completion token representing the currently running coroutine. Constructed around lines 62-130 in spawn.hpp, this class holds a pointer to the underlying spawned_thread_base and the associated executor. When passed to an asynchronous operation, the yield token captures the suspension point; the coroutine resumes automatically when the operation completes.
Cancellation State Management
Each spawned coroutine carries a cancellation_state that can be queried via cancelled() or reset through reset_cancellation_state(). These APIs appear in basic_yield_context (lines 24-60) and spawned_thread_base (lines 86-98), allowing coroutines to respond gracefully to termination requests.
Launching Your First Coroutine with asio::spawn
To create a stackful coroutine, invoke asio::spawn with an executor or execution context and a callable that accepts a yield_context parameter. The primary overload for an executor resides at lines 71-104 in spawn.hpp.
#include <asio.hpp>
#include <iostream>
void echo_server(asio::yield_context yield)
{
try
{
asio::ip::tcp::acceptor acceptor(yield.get_executor(),
asio::ip::tcp::endpoint(asio::ip::tcp::v4(), 12345));
for (;;)
{
asio::ip::tcp::socket socket(yield.get_executor());
acceptor.async_accept(socket, yield); // <- suspend
char data[512];
for (;;)
{
std::size_t n = socket.async_read_some(
asio::buffer(data), yield); // <- suspend
if (n == 0) break;
asio::async_write(socket,
asio::buffer(data, n), yield); // <- suspend
}
}
}
catch (std::exception& e)
{
std::cerr << "Echo server error: " << e.what() << "\n";
}
}
int main()
{
asio::io_context io;
asio::spawn(io, echo_server);
io.run();
}
asio::spawn(io, echo_server) creates the coroutine and binds it to io's default executor. The yield parameter is passed to each async operation; the call returns the operation's result once the coroutine resumes.
Working with Executors and Strands
When thread safety is required, pass a strand as the first argument to asio::spawn. The coroutine inherits the strand's executor, ensuring that all async operations inside the coroutine execute without concurrent interference.
void serial_task(asio::yield_context yield)
{
// Operations here are serialized through the strand
asio::steady_timer timer(yield.get_executor());
timer.expires_after(std::chrono::seconds(1));
timer.async_wait(yield);
}
int main()
{
asio::io_context io;
asio::strand<asio::io_context::executor_type> strand(io.get_executor());
asio::spawn(strand, serial_task);
io.run();
}
Error Handling in Stackful Coroutines
By default, yield throws a system_error when an asynchronous operation fails. To capture errors explicitly, use yield[ec] syntax, which accesses the operator[] overload defined at lines 108-113 in spawn.hpp.
void client(asio::yield_context yield)
{
asio::ip::tcp::socket socket(yield.get_executor());
asio::error_code ec;
socket.async_connect(
asio::ip::tcp::endpoint(
asio::ip::make_address("127.0.0.1"), 12345),
yield[ec]);
if (ec)
{
std::cerr << "Connect failed: " << ec.message() << "\n";
return;
}
}
Implementing Cancellation Support
Coroutines can check for cancellation requests using yield.cancelled(). The implementation in spawned_thread_base tracks cancellation state, which can be triggered via cancellation_signal objects.
void cancellable_task(asio::yield_context yield)
{
for (int i = 0; i < 10; ++i)
{
if (yield.cancelled() != asio::cancellation_type::none)
throw asio::system_error(asio::error::operation_aborted);
asio::steady_timer timer(yield.get_executor(),
std::chrono::seconds(1));
timer.async_wait(yield);
}
}
int main()
{
asio::io_context io;
asio::cancellation_signal sig;
asio::spawn(io,
[&](asio::yield_context y){ cancellable_task(y); },
asio::bind_cancellation_slot(sig.slot(), asio::detached));
std::thread([&]{
std::this_thread::sleep_for(std::chrono::seconds(3));
sig.emit(asio::cancellation_type::terminal);
}).detach();
io.run();
}
The coroutine accesses its cancellation slot via yield.get_cancellation_slot(), allowing graceful termination when sig.emit() is called.
Summary
asio::spawncreates stackful coroutines that integrate with Asio's asynchronous model throughbasic_yield_context.spawned_thread_base(defined ininclude/asio/spawn.hpp) provides the underlying suspension and resumption machinery.- Strands can be passed directly to
asio::spawnto ensure serialized execution within the coroutine. - Error handling uses
yield[ec]to capture error codes instead of throwing exceptions. - Cancellation is supported through
yield.cancelled()andreset_cancellation_state(), with state managed ininclude/asio/cancellation_state.hpp.
Frequently Asked Questions
What is the difference between stackful and stackless coroutines in Asio?
Stackful coroutines, created with asio::spawn, maintain their own stack and can suspend from anywhere within the function call tree. Stackless coroutines (C++20 coroutines) use co_await and require explicit suspension points. Stackful coroutines often yield simpler code for complex control flow but consume more memory per coroutine.
How does basic_yield_context work internally?
basic_yield_context holds a pointer to a spawned_thread_base object and the associated executor. When passed to an asynchronous operation, it acts as a completion token—initiate_spawn (defined in include/asio/impl/spawn.hpp) creates the coroutine object, and the yield token captures the resume point. When the async operation completes, Asio invokes the token to resume the coroutine.
Can I use asio::spawn with existing asynchronous functions?
Yes. Any Asio function accepting a completion token can accept yield or yield[ec]. This includes async_read, async_write, async_accept, and timer waits. The function signature must simply accept a yield_context parameter, and the coroutine will suspend until the operation completes.
How do exceptions propagate in spawned coroutines?
If an exception escapes the coroutine function and a completion handler was provided to asio::spawn, the exception is caught and passed to the handler via std::exception_ptr. If using asio::detached or no handler, the exception propagates to the event loop's exception handling mechanism. Use try-catch blocks inside the coroutine function to handle errors locally.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →