Search code examples
c++boost-asioenable-shared-from-this

How to correctly use enable_shared_from_this with io_context


I am trying to understand different scenarios of cyclic references while using ioContext and shared_ptr and I stumbled into this.

In the code below class Test has a member variable shared_ptr<io_context>. When I make a the lambda inside of function ioContext post inside of async_wait I pass shared_from_this() {which is Test} in it. Imagine now ioContext is running in a different thread so the lambda gets added to internal ioContext queue. According to my understanding the lambda goes inside of ioContext internal queue and now there is a cyclic reference Test --> ioContext --> lambda --> Test.

Is my understanding correct here ? Is there any way to avoid this ?

https://coliru.stacked-crooked.com/a/586afa794d15ef8f

#include <boost/asio.hpp>
#include <boost/date_time.hpp>
#include <iostream>

std::string getTime()
{
    return std::string("[").append(to_iso_extended_string(boost::posix_time::microsec_clock::universal_time())).append("] ");
}

class Test : public std::enable_shared_from_this<Test> {
public:
    Test(const std::shared_ptr<boost::asio::io_context>& ioContext)
        : ioContext_(ioContext), timer_(*ioContext) {}

    void start() {
        std::cout << getTime() << "at start\n";
        timer_.expires_after(std::chrono::seconds(3));
        timer_.async_wait([this, self=shared_from_this()](const boost::system::error_code& ec)
        {
            std::cout << getTime() << "Inside wait!\n";
            if (!ec) 
            {
                ioContext_->post([this, self=shared_from_this()]()
                {
                    printInPost();
                });
            }
        });
    }

    void printInPost() {
        std::cout << getTime() << "I get printed!\n";
    }

private:
    std::shared_ptr<boost::asio::io_context> ioContext_;
    boost::asio::steady_timer timer_;
};

int main() {
    std::shared_ptr<boost::asio::io_context> ioContext = std::make_shared<boost::asio::io_context>();
    std::shared_ptr<Test> myTest = std::make_shared<Test>(ioContext);
    
    myTest->start();
    
    ioContext->run();
}

Solution

  • Async operations aren't responsible for the lifetime of the execution context. You can simplify and correct as follows:

    Live On Coliru

    #include <boost/asio.hpp>
    #include <iostream>
    namespace asio = boost::asio;
    using namespace std::chrono_literals;
    
    static auto elapsedSeconds() {
        static auto start = std::chrono::steady_clock::now();
        return (std::chrono::steady_clock::now() - start) / 1.s;
    }
    static int generateId() {
        static std::atomic_int id;
        return ++id;
    }
    
    struct Test : std::enable_shared_from_this<Test> {
        Test(asio::any_io_executor ex) : timer_(ex) {}
    
        void start() {
            trace() << "start" << std::endl;
            timer_.expires_after(3s);
            timer_.async_wait([this, self = shared_from_this()](boost::system::error_code const& ec) {
                trace() << "Inside wait! (" << ec.message() << ")" << std::endl;
                if (!ec)
                    post(timer_.get_executor(), [this, self] { printInPost(); });
            });
        }
    
      private:
        int const          id_ = generateId();
        asio::steady_timer timer_;
    
        void printInPost() const {
            trace() << "I get printed!" << std::endl;
        }
    
        std::ostream& trace() const {
            return std::cout << "id:" << id_ << " " << elapsedSeconds() << " ";
        }
    };
    
    int main() {
        std::cout << std::fixed;
        {
            asio::io_context ioc;
            std::make_shared<Test>(ioc.get_executor())->start();
            ioc.run();
        }
        // look ma, decoupled!
        {
            asio::thread_pool ioc(4); // use 4 threads
            std::make_shared<Test>(ioc.get_executor())->start();
            ioc.join();
        }
    }
    

    Printing the expected:

    id:1 0.000002 start    
    id:1 3.000153 Inside wait! (Success)    
    id:1 3.000226 I get printed!    
    id:2 3.000516 start    
    id:2 6.000720 Inside wait! (Success)    
    id:2 6.000794 I get printed!
    

    Backgrounds

    Some detailed backgrounds, starting from Async Operations:

    Instead, async operations are owned by the context. Async operations are initiated by initiation functions. All initiation functions enqueue at least one handler.

    The handler is guaranteed to be invoked unless the context is stopped. In our code, run() and join() only return when the context runs out of work, so after completion.

    Destructing the execution context is safe when no threads are running the scheduler. The destructor will release all pending handlers.

    UPDATE

    To @Caleths comment, to ensure that a context doesn't complete, the holder of an executor can store a work_guard e.g. see make_work_guard. Also see more background than you will probably want

    Allocation Guarantees

    The same documentation page has good detail on this. Often, asynchronous operations are more stateful and might own temporary resources. The async completion model requires these resources be released before invocation of the completion handler:

    If an asynchronous operation requires a temporary resource (such as memory, a file descriptor, or a thread), this resource is released before calling the completion handler.

    [...]

    By ensuring that resources are released before the completion handler runs, we avoid doubling the peak resource usage of the chain of operations

    It follows handlers are dequeued before invocation.