Introduction
boost::asio::spawn() launches a stackful coroutine that runs on an executor. You must pass an io_context (or its executor) when the coroutine is not associated with an existing Asio object that already has an executor. If you spawn from within a handler that is already bound to an executor (like a socket's executor), you can pass that executor or the socket's strand instead. The rule is simple: spawn needs to know which executor to run the coroutine on.
When to Pass io_context
1#include <boost/asio.hpp>
2#include <boost/asio/spawn.hpp>
3
4int main() {
5 boost::asio::io_context io;
6
7 // Must pass io_context — no existing executor context
8 boost::asio::spawn(io, [](boost::asio::yield_context yield) {
9 // Coroutine body runs on io's executor
10 boost::asio::steady_timer timer(yield.get_executor(), std::chrono::seconds(1));
11 timer.async_wait(yield);
12 std::cout << "Timer fired!\n";
13 });
14
15 io.run();
16}
When spawn is called from main() or any non-Asio context, there is no implicit executor. You must provide io_context explicitly.
When You Can Use an Existing Executor
1boost::asio::spawn(io, [&](boost::asio::yield_context yield) {
2 boost::asio::ip::tcp::socket socket(io);
3
4 // Inside a coroutine, spawn a new coroutine on the same executor
5 // Can use the yield_context's executor instead of io_context
6 boost::asio::spawn(yield, [](boost::asio::yield_context yield2) {
7 boost::asio::steady_timer t(yield2.get_executor(), std::chrono::seconds(2));
8 t.async_wait(yield2);
9 std::cout << "Nested coroutine done\n";
10 });
11});
When spawning from within a coroutine, you can pass the yield_context directly. The new coroutine inherits the same executor.
Using a Strand
1boost::asio::io_context io;
2auto strand = boost::asio::make_strand(io);
3
4// Spawn on a strand for serialized execution
5boost::asio::spawn(strand, [](boost::asio::yield_context yield) {
6 // All coroutine operations run through the strand
7 // No concurrent execution with other handlers on the same strand
8});
9
10io.run();
Passing a strand instead of io_context ensures the coroutine's continuation handlers are serialized with other handlers on that strand. This is critical for thread safety when multiple threads call io.run().
Multiple Threads — Why Strands Matter
1boost::asio::io_context io;
2auto strand = boost::asio::make_strand(io);
3
4// Shared state
5int counter = 0;
6
7// Without strand — DATA RACE
8boost::asio::spawn(io, [&counter](boost::asio::yield_context yield) {
9 counter++; // Unsafe if multiple threads run io.run()
10});
11
12// With strand — SAFE
13boost::asio::spawn(strand, [&counter](boost::asio::yield_context yield) {
14 counter++; // Serialized — no data race
15});
16
17// Run on 4 threads
18std::vector<std::thread> threads;
19for (int i = 0; i < 4; i++) {
20 threads.emplace_back([&io] { io.run(); });
21}
22for (auto& t : threads) t.join();
Spawn Overloads
1// 1. Pass io_context
2boost::asio::spawn(io, handler);
3
4// 2. Pass executor (from io_context or strand)
5boost::asio::spawn(io.get_executor(), handler);
6boost::asio::spawn(strand, handler);
7
8// 3. Pass yield_context (inherit executor from parent coroutine)
9boost::asio::spawn(yield, handler);
10
11// 4. Pass with completion token (Boost.Asio 1.80+)
12boost::asio::spawn(io, handler, boost::asio::detached);
The completion token (detached) tells Asio what to do when the coroutine completes. Without it, older versions default to throwing on error.
TCP Server Example
1void session(boost::asio::ip::tcp::socket socket, boost::asio::yield_context yield) {
2 char buf[1024];
3 boost::system::error_code ec;
4
5 for (;;) {
6 std::size_t n = socket.async_read_some(
7 boost::asio::buffer(buf), yield[ec]);
8
9 if (ec) break;
10
11 boost::asio::async_write(
12 socket, boost::asio::buffer(buf, n), yield[ec]);
13
14 if (ec) break;
15 }
16}
17
18void listener(boost::asio::io_context& io, unsigned short port,
19 boost::asio::yield_context yield) {
20 boost::asio::ip::tcp::acceptor acceptor(
21 io, {boost::asio::ip::tcp::v4(), port});
22
23 for (;;) {
24 boost::system::error_code ec;
25 auto socket = acceptor.async_accept(yield[ec]);
26 if (ec) break;
27
28 // Spawn a new coroutine for each connection
29 // Use socket's executor to ensure proper context
30 boost::asio::spawn(socket.get_executor(),
31 [s = std::move(socket)](boost::asio::yield_context yield) mutable {
32 session(std::move(s), yield);
33 });
34 }
35}
36
37int main() {
38 boost::asio::io_context io;
39
40 // Must pass io_context — starting from main
41 boost::asio::spawn(io,
42 [&io](boost::asio::yield_context yield) {
43 listener(io, 8080, yield);
44 });
45
46 io.run();
47}
Error Handling
1boost::asio::spawn(io, [](boost::asio::yield_context yield) {
2 boost::system::error_code ec;
3 boost::asio::steady_timer timer(yield.get_executor(), std::chrono::seconds(5));
4
5 // Option 1: Exception (default)
6 try {
7 timer.async_wait(yield);
8 } catch (boost::system::system_error& e) {
9 std::cerr << "Error: " << e.what() << "\n";
10 }
11
12 // Option 2: Error code (no exception)
13 timer.async_wait(yield[ec]);
14 if (ec) {
15 std::cerr << "Error: " << ec.message() << "\n";
16 }
17});
yield[ec] captures the error code instead of throwing. This is the preferred pattern for performance-sensitive code.
Common Pitfalls
Forgetting to call io.run(): Coroutines spawned on an io_context do not run until io.run() is called. The program exits immediately without it.
Spawning on a destroyed io_context: If the io_context goes out of scope while coroutines are running, behavior is undefined. Keep the io_context alive until all work is done.
No strand with multiple threads: Spawning directly on io_context with multiple threads calling io.run() causes data races in coroutine continuations. Use make_strand() when sharing state.
Mixing stackful and stackless coroutines: boost::asio::spawn creates stackful coroutines (Boost.Context). C++20 co_await creates stackless coroutines. They use different mechanisms and should not be confused.
Large stack allocation: Each stackful coroutine allocates a stack (default 64KB-1MB depending on platform). Spawning thousands of coroutines can consume significant memory. Use boost::asio::spawn with a custom stack allocator for high-concurrency scenarios.
Summary
Pass io_context to spawn when there is no existing executor (e.g., from main())
Pass a strand for thread-safe coroutine execution with multiple threads
Pass yield_context from a parent coroutine to inherit its executor
Use yield[ec] for error code-based error handling instead of exceptions
Always use strands when coroutines share mutable state across threads
Each stackful coroutine allocates its own stack — be mindful of memory with many coroutines