Search code examples
c++boost-asioasio

Asio difference between prefer, require and make_work_guard


In the following example I start a worker thread for my application. Later I post some work to it. To prevent it from returning prematurely I have to ensure "work" is outstanding. I do this with a work_guard object. However I have found two other ways to "ensure" work. Which one should I use throughout my application? Is there any difference?

#include <boost/asio.hpp>
#include <boost/thread.hpp>
#include <syncstream>
#include <iostream>

namespace asio = boost::asio;

int main() {
  asio::io_context workerIO;
  boost::thread workerThread;
  {
    // ensure the worker io context stands by until work is posted at a later time
    // one of the below is needed for the worker to execute work which one should I use?
    auto prodWork = asio::make_work_guard(workerIO);
    // prodWork.reset(); // can be cleared
    asio::any_io_executor prodWork2 = asio::prefer(workerIO.get_executor(), asio::execution::outstanding_work_t::tracked);
    // prodWork2 = asio::any_io_executor{}; // can be cleared
    asio::any_io_executor prodWork3 = asio::require(workerIO.get_executor(), asio::execution::outstanding_work_t::tracked);
    // prodWork3 = asio::any_io_executor{}; // can be cleared

    workerThread = boost::thread{[&workerIO] {
      std::osyncstream(std::cout) << "Worker RUN START: " << std::hash<std::thread::id>{}(std::this_thread::get_id()) << std::endl;
      workerIO.run();
      std::osyncstream(std::cout) << "Worker RUN ENDED: " << std::hash<std::thread::id>{}(std::this_thread::get_id()) << std::endl;
    }};
    asio::io_context appIO;


    std::osyncstream(std::cout) << "Main RUN START: " << std::hash<std::thread::id>{}(std::this_thread::get_id()) << std::endl;

    // schedule work here
    auto timer = asio::steady_timer{appIO};
    timer.expires_after(std::chrono::seconds(4));
    timer.async_wait([&workerIO] (auto ec) {
      std::osyncstream(std::cout) << "Main: timer expired " << std::hash<std::thread::id>{}(std::this_thread::get_id()) << std::endl;
      asio::post(workerIO.get_executor(), [] {
        std::osyncstream(std::cout) << "Worker WORK DONE " << std::hash<std::thread::id>{}(std::this_thread::get_id()) << std::endl;
      });
      std::osyncstream(std::cout) << "Main: work posted to worker " << std::hash<std::thread::id>{}(std::this_thread::get_id()) << std::endl;
    });

    appIO.run();
    std::osyncstream(std::cout) << "Main RUN ENDED: " << std::hash<std::thread::id>{}(std::this_thread::get_id()) << std::endl;
  }
  workerThread.join(); // wait for the worker to finish its posted work
  std::osyncstream(std::cout) << "Main EXIT: " << std::hash<std::thread::id>{}(std::this_thread::get_id()) << std::endl;
  return 0;
}

Are there any examples for how and when to use asio::prefer and asio::require?

Docs:

https://www.boost.org/doc/libs/develop/doc/html/boost_asio/reference/io_context.html https://www.boost.org/doc/libs/1_78_0/doc/html/boost_asio/reference/executor_work_guard.html


Solution

  • My knowledge comes from e.g. WG22 P0443R12 "A Unified Executors Proposal for C++".

    Some differences up front: a work-guard

    • does not alter the executor, instead just calling on_work_started() and on_work_finished() on it. [It is possible to have an executor on which both of these have no effect.]
    • can be reset() independent of its lifetime, or that of any executor instance. Decoupled lifetime is a feature.

    On the other hand, using prefer/require to apply outstanding_work sub-properties:

    • modifies existing executors
    • notably when copied, all copies will have the same properties. This could be dangerous for something as invasive as keeping an execution context/resources around.

    Scanning The Field

    However, not all properties are requirable in the first place. Doing some reconaissance using Ex defined as:

    using namespace boost::asio::execution;
    using boost::asio::io_context;
    using Ex = io_context::executor_type;
    

    First and foremost, check what properties can even be required/preferred (all static assertions shown pass):

    using namespace boost::asio::execution;
    static_assert(not outstanding_work_t::is_preferable);
    static_assert(not outstanding_work_t::is_requirable);
    static_assert(outstanding_work_t::untracked_t::is_preferable);
    static_assert(outstanding_work_t::tracked_t::is_requirable);
    

    So far so good. Let's also check that the properties apply to Ex:

    static_assert(boost::asio::is_applicable_property_v<Ex, outstanding_work_t::tracked_t>);
    static_assert(boost::asio::is_applicable_property_v<Ex, outstanding_work_t::untracked_t>);
    

    Excellent. Observe that it's default value is untracked:

    static_assert(outstanding_work.static_query<Ex>() != outstanding_work.tracked);
    static_assert(outstanding_work.static_query<Ex>() == outstanding_work.untracked);
    

    Indeed, requiring untracked doesn't change the executor type, but requiring tracked does:

    boost::asio::io_context ioc;
    
    using boost::asio::require;
    auto ex           = ioc.get_executor();
    auto untracked_ex = require(ex, outstanding_work.untracked);
    auto tracked_ex   = require(ex, outstanding_work.tracked);
    
    static_assert(std::is_same_v<Ex, decltype(untracked_ex)>);
    static_assert(not std::is_same_v<Ex, decltype(tracked_ex)>);
    

    The difference is that the Bits template argument for io_context::basic_executor_type became 4, which does match expectations:

      struct io_context_bits
      {
        BOOST_ASIO_STATIC_CONSTEXPR(uintptr_t, blocking_never = 1);
        BOOST_ASIO_STATIC_CONSTEXPR(uintptr_t, relationship_continuation = 2);
        BOOST_ASIO_STATIC_CONSTEXPR(uintptr_t, outstanding_work_tracked = 4);
        BOOST_ASIO_STATIC_CONSTEXPR(uintptr_t, runtime_bits = 3);
      };
    

    Now, it does seem that the tracked executor has a similar observable effect as a executor_work_guard:

    timed_run("No work guard", [] {
        io_context ioc;
        Ex ex = ioc.get_executor();
        ioc.run_for(1s);
    });
    
    timed_run("Work guard", [] {
        io_context ioc;
        Ex ex = ioc.get_executor();
        auto w = make_work_guard(ex);
        ioc.run_for(1s);
    });
    
    timed_run("Tracked executor", [] {
        io_context ioc;
        auto ex = require(ioc.get_executor(), outstanding_work.tracked);
        
        ioc.run_for(1s);
    });
    

    Indeed, we can check that the observable property of "locking" execution context is propagated to copies:

    timed_run("Copied tracked executor", [] {
        io_context ioc;
    
        auto original = std::make_optional(
            require(ioc.get_executor(), outstanding_work.tracked));
    
        auto copy = *original;
    
        original.reset();
    
        ioc.run_for(1s);
    });
    

    Prints

    No work guard: 0ms
    Work guard: 1000ms
    Tracked executor: 1000ms
    Copied tracked executor: 1000ms
    

    However, is it actually the same?

    Deep Dive

    The reason I'm not convinced it's semantically the same, even after the above superficial confirmations, is the wording in the paper:

    enter image description here

    Highlighting some parts:

    The existence of the executor object represents an indication of likely future submission of a function object. The executor or its associated execution context may choose to maintain execution resources in anticipation of this submission.

    And specifically in the Note:

    [Note: The outstanding_work_t::tracked_t and outstanding_work_t::untracked_t properties are used to communicate to the associated execution context intended future work submission on the executor. The intended effect of the properties is the behavior of execution context’s facilities for awaiting outstanding work; specifically whether it considers [...] outstanding work when deciding what to wait on. However this will be largely defined by the execution context implementation.

    All in all, the main idea I get here is that:

    • an executor with tracked_t property indicates likelihood of future work, but isn't work
    • the implementation of the execution context defines whether that difference is observable

    Deep-diving for our chosen execution context and executor type:

    /// Executor implementation type used to submit functions to an io_context.
    template <typename Allocator, uintptr_t Bits>
    class io_context::basic_executor_type :
      detail::io_context_bits, Allocator
    {
    public:
      /// Copy constructor.
      basic_executor_type(
          const basic_executor_type& other) BOOST_ASIO_NOEXCEPT
        : Allocator(static_cast<const Allocator&>(other)),
          target_(other.target_)
      {
        if (Bits & outstanding_work_tracked)
          if (context_ptr())
            context_ptr()->impl_.work_started();
      }
    

    As you can see for this particular case the executor with outstanding_work.tracked does end up with the same behavior as a executor_work_guard<> for that same executor/execution context.

    SUMMARY/CONCLUSIONS

    So, for all intents and purposes the only pure/correct way to make a work-guard is using executor_work_guard<>.

    However, for some execution contexts and executors the effect may be similar.

    This might also be a good time to reiterate my initial comment that executor_work_guard expresses intent, and has some other advantages listed at the top.