Search code examples
c++asynchronousboostimplementationasio

How does boost::asio::yield_context work?


I'm reading through the documentation for boost::asio and came across this example:

void foo(boost::asio::yield_context yield)
{
  size_t n = socket.async_read_some(buffer, yield);

  // ...
}

I am quite perplexed:

  1. If I understand asyncio correctly, the call to async_read_some does not block.
  2. This means that this call will register the remainder of the function to run when the data is actually ready to be read.

I see two options for how boost::asio implements this:

  1. When socket.async_read_some is called, this function itself calls a function in the event loop which attempts to find any events which are ready to be processed and tries to call these callbacks.
  2. Some setjmp/longjmp magic registers a callback for this function and it is returned to at a later point.

Both options seem to not work:

  1. The callstack would look something like:
...
boost_internal_magic
async_read_some
foo
...
main

Because we haven't yet returned from foo. But if all functions are called using yield_context I don't see how we'd actually register a callback because there is no code point to jump to as a function call.

  1. There is only 1 stack, but the whole point of asyncio is to run multiple concurrent "agents" or "coroutines" or what have you on the same thread. Each agent would need its own stack, so this doesn't seem to work either.

How does a boost::asio::yield_context turn a function into a callback which can be registered? What would minimal no dependency c++ code look like which has the same functionality?


Solution

  • The trick is that yield_context employs stackful coroutines. The coroutine has it's own separate stack, which is preserved across suspend/resume cycles.

    The technical implementation of this is in Boost Context. Earlier versions of Asio actually implemented this on top of another library: Boost Coroutine (which uses Boost Context under the hood).

    Asio 1.24.0 / Boost 1.80 changed to skip the middle man:

    When targeting C++11 and later, these functions are implemented in terms of Boost.Context directly. The existing overloads have been retained but are deprecated.

    Further Reading

    You can read up on the technical workings of Boost Context at that library's documentation: https://www.boost.org/doc/libs/1_86_0/libs/context/doc/html/index.html

    To zoom on on the particulars of the implementation, you can follow where the implementation leads, e.g. in:

    Live On Coliru

    #define BOOST_ASIO_DISABLE_BOOST_COROUTINE 1
    #include <boost/asio.hpp>
    #include <boost/asio/spawn.hpp>
    
    namespace asio = boost::asio;
    using namespace std::chrono_literals;
    
    int main() {
        asio::io_context ctx;
    
        spawn(
            ctx,
            [](asio::yield_context yield) {
                asio::steady_timer tim(yield.get_executor(), 3s);
                tim.async_wait(yield);
            },
            asio::detached);
    
        ctx.run();
    }
    

    Which would lead you to boost/asio/impl/spawn.hpp, and shows you that the non-Boost.Coroutine implementation actually uses Boost.Context's fiber (BOOST_ASIO_HAS_BOOST_CONTEXT_FIBER).

    // Spawned thread implementation using Boost.Context's fiber.
    class spawned_fiber_thread : public spawned_thread_base
    {
    public:
      typedef boost::context::fiber fiber_type;
    
      spawned_fiber_thread(fiber_type&& caller)
        : caller_(static_cast<fiber_type&&>(caller)),
          on_suspend_fn_(0),
          on_suspend_arg_(0)
      {
      }
    
    // ...
    

    From here you can follow the implementation details arbitrarily deep, right into the various assembly code routines to help in the CPU-specific context switching: https://github.com/boostorg/context/tree/develop/src/asm