Search code examples
c++boostc++20boost-asio

Is executing boost::asio::~strand<> on that same strand supported?


Short version

Is executing boost::asio::~strand<> on that same strand supported?

Found documentation

The docs seems inconsistent, the "legacy/deprecated" boost::asio::io_context::strand::~strand documents

Handlers posted through the strand that have not yet been invoked will still be dispatched in a way that meets the guarantee of non-concurrency.

while boost::asio::strand::~strand does not. I would assume both strands are basically the same in that regard, and that the mentioned sentence is implying, that my scenario is supported, but I does not help the case for a confident answer.

Background i.e. Problem

I use, like boost asio propagtes in its documentation and talks, the shared_from_this life time self management for sessions, like tcp sessions. As nobody except that session class itself holds std::shared_ptrs on itself, a concern comes up: Naturally by this, the code executed by the session itself will eventually deconstruct the last shared_ptr, triggering its own destructor ~session. Which is in the examples of boost::asio of no concern, as the executor is there a boost::asio::context& with longlasting lifetime or similiar and the code is fine with an implicit strand.

But suppose that sessions needs an explicit boost::asio::strand, because we need some concurrent business code or keep alive timers on our own, which is used as the executor. Then last scope closing } code that will execute ~session(), which will execute ~strand() while executing on that exact strand.

Example

#include <iostream>
#include <memory>

#include <boost/asio.hpp>

//More specific could be a tcp session for example
struct session : std::enable_shared_from_this<session> {
    session (boost::asio::io_context& context) : strand(boost::asio::make_strand(context)), timer(context){}
    void start() {
        boost::asio::co_spawn(strand, loop(shared_from_this()), boost::asio::detached);
    }

    //Lets say is called from another co_routine, like a tcp eof
    void stop () {
        boost::asio::post(boost::asio::bind_executor(strand, [me = shared_from_this()] () {
            me->timer.cancel();
        }));
    }

private:
    boost::asio::strand<boost::asio::io_context::executor_type> strand;
    boost::asio::steady_timer timer;

    boost::asio::awaitable<void> loop(std::shared_ptr<session> self) {
        while (true)  {
            timer.expires_from_now(std::chrono::seconds{5});
            co_await timer.async_wait(boost::asio::use_awaitable);
            std::cout << "5 seconds expired" << std::endl;
            self->stop();
        }
    } //Triggers ~session which, deconstructs strand, implications? UB?
};


int main(int argc, char* argv[]) {
    boost::asio::io_context context;
    {
        auto s = std::make_shared<session>(context);
        s->start();
    }
    while (true) {
        try {
            context.run();
            break;
        } catch (std::exception const& e) {
            std::cerr << "Exception in context::run(): " << e.what() << std::endl;
        }
    }
}


Solution

  • No this is not an issue. The reason you don't have to worry is that the "new strand" is an executor.

    Executors are cheaply copyable and passed by value. Anything that holds on to it will have a copy of it. You can think of executors like a handle. Of course, the implementation still lives in the corresponding service registered in an execution context.

    Note though that the different types of strands you are comparing in your question have different service implementations. It will be more efficient to stick to one style of strands (modern style of course).

    Simpler + Explicit: Executors, not Context references

    I'd always write Asio code around executors:

    Live On Coliru

    #include <boost/asio.hpp>
    #include <iostream>
    #include <memory>
    using namespace std::chrono_literals;
    namespace asio = boost::asio;
    
    using Executor = asio::any_io_executor;
    
    struct session : std::enable_shared_from_this<session> {
        session(Executor context) : timer(make_strand(context)) {}
    
        void start() {
            co_spawn(timer.get_executor(), loop(shared_from_this()), asio::detached);
        }
    
        void stop() {
            asio::post(timer.get_executor(), [me = shared_from_this()]() { me->timer.cancel(); });
        }
    
    private:
        asio::steady_timer timer;
    
        asio::awaitable<void> loop(std::shared_ptr<session> self) {
            while (true)  {
                timer.expires_from_now(5s);
                co_await timer.async_wait(asio::use_awaitable);
                std::cout << "5 seconds expired" << std::endl;
                self->stop();
            }
        }
    };
    
    int main() {
        asio::thread_pool context;
        {
            auto s = make_shared<session>(context.get_executor());
            s->start();
            std::this_thread::sleep_for(7s);
            s->stop();
        }
    
        context.join();
    }
    

    Cancellation Slots?

    Of course, now nothing merits shared ownership any more, so you'd equivalently write it functionally:

    Live On Compiler Explorer

    #include <boost/asio.hpp>
    #include <iostream>
    #include <memory>
    using namespace std::chrono_literals;
    
    namespace asio = boost::asio;
    using Executor = asio::thread_pool::executor_type; // also optimize avoiding type-erasure and allocations
    using Action   = asio::awaitable<void, Executor>;
    
    Action do_session(asio::cancellation_slot sig) {
        auto token = asio::bind_cancellation_slot(sig, asio::deferred);
    
        asio::steady_timer timer{co_await asio::this_coro::executor};
    
        while (true) {
            timer.expires_from_now(5s);
            co_await timer.async_wait(token);
            std::cout << "5 seconds expired" << std::endl;
        }
    };
    
    int main() {
        asio::thread_pool context;
        {
            asio::cancellation_signal sig;
            co_spawn(context.get_executor(), do_session(sig.slot()), asio::detached);
    
            std::this_thread::sleep_for(7s);
            sig.emit(asio::cancellation_type::terminal);
        }
    
        context.join();
    }
    

    Still printing

    enter image description here