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
My knowledge comes from e.g. WG22 P0443R12 "A Unified Executors Proposal for C++".
Some differences up front: a work-guard
on_work_started()
and on_work_finished()
on it. [It is possible to have an executor on which both of these have no effect.]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:
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?
The reason I'm not convinced it's semantically the same, even after the above superficial confirmations, is the wording in the paper:
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
andoutstanding_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:
tracked_t
property indicates likelihood of future work, but isn't workDeep-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.
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.