When must you pass io_context to boost::asio::spawn? (C++)
Asked Answered
M

1

4

I was surprised to find that the code below works without passing io_context as the first argument to spawn. Could somebody please explain why I don't need to pass it in this case, and in what situations you must explicitly pass it. I'm using Boost 1.75.0.

#include <boost/asio/spawn.hpp>
#include <boost/asio/deadline_timer.hpp>
#include <iostream>

int main() {

  boost::asio::io_context io_context;
  boost::asio::deadline_timer timer(io_context);

  boost::asio::spawn([&](boost::asio::yield_context yield){ // don't need to pass io_context?!
    std::cout << "started spawn" << std::endl;
    timer.expires_from_now(boost::posix_time::seconds(5));
    timer.async_wait(yield);
    std::cout << "finished spawn" << std::endl;
  });

  std::cout << "running io_context" << std::endl;
  io_context.run();
  std::cout << "finished running io_context" << std::endl;

}
Matted answered 21/3, 2021 at 11:55 Comment(5)
Thanks have specified Boost 1.75.0.Matted
I would expect that if you don't pass an executor, the continuation will be posted on the executor associated with the object you are awaiting (e.g. timer.get_executor() in your case).Bander
That makes sense to me too. But I don't understand why you'd ever need to pass io_context to spawn at all?Matted
@Bander is right; this is what happens when the async operations inside the coro are posted, but it says nothing about where the coro itself is posted, which I think is the question.Origami
My answer doesn't focus on the title question "When must xxxx". Because the answer is subjective: when you want. I hope my answer highlights why you would want to, or when you wouldn't. When in doubt, I'd personally prefer to be explicit, so I'd even go as far as explicitly passing the fallback executor so it is clear to the reader.Origami
O
6

Asio has added the concept of associated executors and default executors.

The associated executors is not really new, because the handler_invoke protocol already allowed for handler-type specific semantics. However, since the formulation of the executor concept it became more generalized.

Now you can post any handler, and it will execute on the associated executor, the executor supplied or the default executor. The default executor is ultimately system_executor{}.

So

post([]{ puts("Hello world"); });
post(system_executor{}, []{ puts("Hello world"); });

Both invoke the handler using system_executor.

You can bind an associated handler with any handler that doesn't associate one already:

post(bind_executor(ex1, []{ puts("Hello world"); }));
post(system_executor{}, bind_executor(ex1, []{ puts("Hello world"); }));

Both run the handler on ex1, not the fallbacks. Combining the above, you will already expect that this does the same:

post(ex1, []{ puts("Hello world"); });

(here, the handler has not associated executor, so ex1 functions as the fallback)

Spawn

Spawn is merely a wrapper that "posts" another type of handler¹. Indeed it is documented to use any associated executor. The implementation reflects this quite readably:

template <typename Function>
inline void spawn(BOOST_ASIO_MOVE_ARG(Function) function,
    const boost::coroutines::attributes& attributes)
{
  typedef typename decay<Function>::type function_type;

  typename associated_executor<function_type>::type ex(
      (get_associated_executor)(function));

  boost::asio::spawn(ex, BOOST_ASIO_MOVE_CAST(Function)(function), attributes);
}

You can see that get_associated_executor is called without explicit fallback, defaulting to system_executor again.

Side Notes

In addition

  • spawn will add a strand where appropriate (this is a reason why providing a concurrency hint when constructing your execution context can make a big difference)
  • spawn can take a yield_context as the first argument, in which case you will effectively run on the same strand (sharing the executor)

¹ It's an implementation detail, but it will generally be boost::asio::detail::spawn_helper<...> which correctly propagates associated executors/allocators again. I would dub this kind of type a "handler binder"

LIVE DEMO

To illustrate the reality that system_executor is being used, here's a simplified tester:

Compiler Explorer

#include <boost/asio/spawn.hpp>
#include <boost/asio/steady_timer.hpp>
#include <iostream>

int main() {
    using namespace boost::asio;
    using namespace std::chrono_literals;
    io_context ctx(1);

    spawn([](yield_context yield) {
        std::cout << "started spawn" << std::endl;

        auto ex = get_associated_executor(yield);
        //auto work = make_work_guard(ex);

        steady_timer timer(ex, 5s);
        timer.async_wait(yield);

        std::cout << "finished spawn" << std::endl;
    });

    std::cout << "running context" << std::endl;
    query(system_executor{}, execution::context).join();
    std::cout << "finished running context" << std::endl;
}

Notes:

  • ctx now takes a concurrency hint (as mentioned above)

  • ctx is never used; joining it would not wait for coro to be completed!

  • Note the commented work. It is important that though async operations constitute work, the Coro itself is not work so you might want to guard the scope of the coro in some situations.

  • Note that system_executor is joined like you would another thread-based execution context like thread_pool:

     query(system_executor{}, execution::context).join();
    

Now it prints

started spawn
running context
finished spawn
finished running context

with the 5s delay as expected.

Origami answered 21/3, 2021 at 14:1 Comment(3)
Clarified the theory by making your sample into a working sample with some annotationsOrigami
Is ctx in the notes the same object as io_context in the code snippet?Bander
@Bander Yeah. Sorry. I forgot I updated that name after I copied the code. Fixed.Origami

© 2022 - 2024 — McMap. All rights reserved.