Boost.Asio
C++
io_context
spawn
concurrency

When must you pass io_context to boostasiospawn? C

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

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

cpp
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

cpp
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

cpp
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

cpp
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

cpp
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

cpp
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

cpp
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

Course illustration
Course illustration

All Rights Reserved.