ASIO co_spawn vs spawn: Choosing Between Stackful and Stackless Coroutines
Use asio::co_spawn for C++20 projects requiring zero-allocation coroutines with minimal memory overhead, and asio::spawn when working with pre-C++20 compilers or when you need true stackful semantics like deep recursion.
The chriskohlhoff/asio library offers two distinct APIs for writing asynchronous code in a sequential style: asio::spawn for stackful coroutines and asio::co_spawn for stackless C++20 coroutines. While both achieve the same goal of simplifying asynchronous logic, they differ fundamentally in implementation, memory usage, and compiler requirements. This guide examines the asio co_spawn vs spawn decision based on the actual source code implementation.
What Are the Two Coroutine Styles?
Stackful Coroutines with asio::spawn
The asio::spawn function creates stackful coroutines using Boost.Context (formerly Boost.Coroutine). Each coroutine receives its own stack segment (approximately 64 KB by default) that is switched during suspension and resumption.
The core type is basic_yield_context<Executor> defined in include/asio/spawn.hpp. When you spawn a coroutine, Asio creates a detail::spawned_thread_base-derived object in include/asio/impl/spawn.hpp that manages the stack allocation and cancellation state. The coroutine function receives a yield_context token that you pass to asynchronous operations.
Stackless Coroutines with asio::co_spawn
The asio::co_spawn function leverages native C++20 coroutines using the asio::awaitable<T> type. Instead of allocating a separate stack, the compiler rewrites the coroutine function into a state machine.
The entry point resides in include/asio/co_spawn.hpp, which constructs a detail::co_spawn_state object (implemented in include/asio/impl/co_spawn.hpp) to hold the awaitable and a work guard for the executor. Each co_await expression yields control back to the Asio executor without stack switching.
Key Differences: Implementation and Performance
Underlying Architecture
asio::spawn relies on stack switching via Boost.Context. When a coroutine suspends, the library saves the current CPU registers and switches to the scheduler's stack (or another coroutine). This happens through the spawned_thread_base::resume() method in include/asio/impl/spawn.hpp.
asio::co_spawn uses the compiler-generated coroutine promise. The function state is stored in a heap-allocated frame containing program counter positions and local variables. Control transfer happens through co_spawn_dispatch and co_spawn_post helpers in include/asio/impl/co_spawn.hpp, which integrate with Asio's async_initiate mechanism.
Memory and Performance Characteristics
The stackful approach allocates a dedicated stack for each coroutine. This incurs higher memory usage (roughly 64 KB per coroutine) but provides true stack semantics where local variables persist across suspension points and deep recursion works naturally.
The stackless approach allocates only the coroutine frame required to store local variables and the resumption point. This results in significantly lower memory footprints and faster resumption times, though the compiler-generated state machine may increase binary size.
Portability Requirements
asio::spawn works on any compiler supporting Boost.Context and does not require C++20. This makes it suitable for legacy codebases or environments with older toolchain requirements.
asio::co_spawn requires full C++20 coroutine support (co_await, co_return) and compliant standard library implementations. It is the preferred choice for modern C++ projects.
Practical Code Examples
Example 1: Stackful Coroutine with asio::spawn
When using asio::spawn, you pass the yield_context as a function argument and use it as a completion token for asynchronous operations.
#include <asio.hpp>
#include <iostream>
void echo_session(asio::ip::tcp::socket socket, asio::yield_context yield)
{
try
{
char data[1024];
for (;;)
{
std::size_t n = socket.async_read_some(asio::buffer(data), yield);
asio::async_write(socket, asio::buffer(data, n), yield);
}
}
catch (std::exception& e)
{
std::cerr << "Session ended: " << e.what() << "\n";
}
}
// Launch the coroutine
asio::io_context io;
asio::ip::tcp::acceptor acceptor(io, {asio::ip::tcp::v4(), 8080});
asio::spawn(io, [&](asio::yield_context yield) {
auto socket = acceptor.async_accept(yield);
echo_session(std::move(socket), yield);
});
io.run();
Key files: include/asio/spawn.hpp, include/asio/impl/spawn.hpp
Example 2: Stackless Coroutine with asio::co_spawn
With asio::co_spawn, the function returns asio::awaitable<T> and uses co_await to suspend execution.
#include <asio.hpp>
#include <iostream>
asio::awaitable<void> echo_session(asio::ip::tcp::socket socket)
{
try
{
char data[1024];
for (;;)
{
std::size_t n = co_await socket.async_read_some(asio::buffer(data));
co_await asio::async_write(socket, asio::buffer(data, n));
}
}
catch (std::exception& e)
{
std::cerr << "Session ended: " << e.what() << "\n";
}
}
// Launch the coroutine
asio::io_context io;
asio::ip::tcp::acceptor acceptor(io, {asio::ip::tcp::v4(), 8080});
asio::co_spawn(io, [&]() -> asio::awaitable<void> {
auto socket = co_await acceptor.async_accept();
co_await echo_session(std::move(socket));
}, asio::detached);
io.run();
Key files: include/asio/co_spawn.hpp, include/asio/impl/co_spawn.hpp
Example 3: Cancellation Handling Comparison
Both APIs support Asio's cancellation framework, but access differs slightly.
Stackful (spawn):
asio::spawn(io, [&](asio::yield_context yield) {
auto cancel_state = yield.get_cancellation_state();
// Check cancel_state.cancelled() or rely on operation cancellation
});
Stackless (co_spawn):
#include <asio/this_coro.hpp>
asio::awaitable<void> task()
{
auto cancel_state = co_await asio::this_coro::cancellation_state;
cancel_state.assign(asio::cancellation_type::all);
// Cancellation slot is attached to the awaiting coroutine
}
Reference: include/asio/this_coro.hpp
Exception Handling and Error Codes
asio::spawn provides two error handling patterns. You can pass an error_code to the yield token: socket.async_read_some(buf, yield[ec]), or let exceptions propagate naturally. The try/catch blocks behave exactly like normal function stacks because they operate on a real stack.
asio::co_spawn propagates exceptions from awaited operations unless you explicitly use the error_code overload: co_await socket.async_read_some(buf, ec). While exception handling is standardized, remember that the coroutine is a compiler-generated state machine, not a traditional stack frame.
When to Choose Which Style
Choose asio::spawn when:
- Your project must compile with pre-C++20 compilers
- You require true stackful semantics for deep recursion or complex call stacks
- You are maintaining existing Boost.Coroutine-based codebases
- You need predictable
try/catchbehavior identical to synchronous code
Choose asio::co_spawn when:
- You are targeting C++20 or later with modern compiler support
- Minimizing memory allocation is critical (no per-coroutine stack allocation)
- You want to blend Asio with other C++20 coroutine libraries (e.g.,
std::generator) - You prefer the explicit
co_awaitsyntax and state machine model
Summary
asio::spawnimplements stackful coroutines using Boost.Context with explicit stack allocation (~64 KB per coroutine), defined ininclude/asio/spawn.hppandinclude/asio/impl/spawn.hpp.asio::co_spawnimplements stackless coroutines using C++20awaitabletypes with compiler-generated state machines, defined ininclude/asio/co_spawn.hppandinclude/asio/impl/co_spawn.hpp.- Memory:
spawnallocates separate stacks;co_spawnallocates only the coroutine frame, resulting in lower memory usage. - Portability:
spawnworks without C++20;co_spawnrequires C++20 coroutine support. - Syntax:
spawnusesyield_contexttokens;co_spawnusesco_awaitexpressions. - Interoperability: Both APIs integrate with Asio's
async_initiateand support the cancellation framework viathis_coro::cancellation_stateoryield.get_cancellation_state().
Frequently Asked Questions
Does asio::spawn require C++20?
No. asio::spawn relies on Boost.Context and works with pre-C++20 compilers. It only requires a compiler that can build the Boost.Coroutine2 or Boost.Context libraries. In contrast, asio::co_spawn requires a compiler with full C++20 coroutine support.
Can I mix asio::spawn and asio::co_spawn in the same project?
Yes. Both APIs ultimately use Asio's async_initiate mechanism and can interoperate through the executor. You can spawn stackful coroutines from within stackless ones (and vice versa) by posting work to the same io_context or executor.
Which coroutine style has better performance?
asio::co_spawn typically offers better performance and lower memory overhead because it avoids allocating separate stacks (~64 KB per coroutine) and uses compiler-optimized state machines. However, asio::spawn may perform better for highly recursive algorithms where stack allocation is actually beneficial.
How do I get cancellation state in a C++20 coroutine?
Use co_await asio::this_coro::cancellation_state inside an awaitable function. This returns a cancellation_state object defined in include/asio/this_coro.hpp, which allows you to check for cancellation requests or assign cancellation types (e.g., cancellation_type::all).
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 →