Search code examples
c++boostboost-asioexecutorc++-coroutine

Boost Asio: Executors in C++20 coroutines


While experimenting with boost::asio::awaitable and Executors, I keep observing some rather confusing behaviour that I would like to understand better

Preparation

Please take a look at the follwing program:

int main(int argc, char const* args[])
{
    boost::asio::io_context ioc;
    auto spawn_strand = boost::asio::make_strand(ioc);

    boost::asio::co_spawn(spawn_strand,
        [&]() -> boost::asio::awaitable<void>
        {
            auto switch_strand = boost::asio::make_strand(ioc);
            co_await boost::asio::post(switch_strand, // (*)
                boost::asio::bind_executor(switch_strand, boost::asio::use_awaitable));

            boost::asio::post(spawn_strand, [](){
                std::cout << "calling handler\n";
            });

            std::this_thread::sleep_for(std::chrono::seconds(3));
            std::cout << "waking up\n";
        },
        boost::asio::detached);

    std::jthread threads[3]; // provide enough threads to serve strands in parallel
    for (auto& thread : threads)
        thread = std::jthread{ [&]() { ioc.run(); }};
}

As expected, this program outputs:

calling handler
waking up

Specifically, "calling handler" is printed before "waking up" since, after line (*), the coroutine no longer runs on spawn_strand. Thus, latter will not get blocked by sleep_for and can run the handler immediately.

So far so good, now let's reveal some confusing behaviour...

Observations

  1. It turns out that asserting spawn_strand == co_await boost::asio::this_coro::executor after line (*) does not fail. We would assume that, at this point, the execution switched to switch_strand and actually just confirmed this fact. Therefore, at this point I'd expect co_await boost::asio::this_coro::executor to compare equal to switch_strand instead of spawn_strand.

  2. If in line (*) we replace the strand passed to post with spawn_strand, the output changes to:

    waking up
    calling handler
    

    I've read other answers (e.g. this or this) regarding the executors supplied to post and bind_executor and generally they suggest that the former serves as a mere fallback in case the handler does not have an associated handler. This does not match with the observed output.

  3. Now, let's take the change of the former paragraph and add

    boost::asio::post(switch_strand, [](){
        std::cout << "calling handler\n";
    });
    

    after line (*). Note that this time we use switch_strand instead of spawn_strand. We will get the following output

    waking up
    calling handler
    calling handler
    

    This suggest that both handlers (on switch_strand as well as on spawn_strand), are blocked by the single sleep_for. Ultimately it seems like after line (*) the coroutine is run on both strands at the same time, which it very irritating.

  4. Bullet point 2. and 3. equally apply when, instead of replacing the stand passed to post, we replace the strand passed to bind_handler by spawn_strand.

Question

How can these strange observations be explained? To me it seem like, in addition to the usual functioning of executors to invoke handlers, boost::asio::awaitables are associated with an additional executor (namely the one provided to co_spawn and accessible via this_coro::executor) permanently present during execution of the coroutine. However, I haven't found any of this explained anywhere; neither the documentation for Boost.Asio C++20 Coroutines Support nor in answers to related questions here. So I don't believe this is how things actually work.


Solution

    1. The assert

      assert(spawn_strand == co_await asio::this_coro::executor);
      

      Is a tautology. You literally spawned the coro on the strand, so it will be the coro strand. What you probably intended to check:

      assert(spawn_strand.running_in_this_thread());
      

      This will fail after the (*) line

    2. The behavior does match the linked answers. The key is that the explicit executor argument is a fallback for the completion token.

      It will be used if no executor is bound. When you're posting naked lambdas, this is the case.

      The linked answers mostly deal with the fact that e.g. asio::use_awaitable does have the coro's executor as its associated executor, which means the explicit executor will not be used for the completion unless overridden with e.g. bind_executor.

    3. I think the previous should already clear this up.

      Ultimately it seems like after line (*) the coroutine is run on both strands at the same time, which it very irritating.

      Good news: you can be on many strands (and for good reason, e.g. if you need synchronized access to several resources)¹.

      Even better to realize that any coroutine is by definition its own logical strand. In fact the stackful coroutines explicitly wrap their executor in a strand. C++20 coroutines may not have to do this, but the language specification specifies resumption in a way that guarantees sequential execution of the coro body.

    4. (I don't think there's a question here)

    This answer shows how to switch a coro to a strand and drop the strand-exclusivity again: ASIO: co_await callable to be run on a strand. At the bottom is a link to a demonstration of how "assuming" multiple strands at the same time works in practice: How do I use an asio::strand in a library that provides both blocking and asynchronous functions


    ¹ actually that was superficial thinking by me; Thinking a bit further makes it clear that this cannot be usefully depended on, see https://stackoverflow.com/a/78597026/85371