1. Scope
We were asked by the chair of LEWG to review how the Executors proposal [P0443R13] makes use of Properties [P1393]. We were to look at all uses of properties within P0443, not just the sections that define properties.
We did not review P1393, where the properties mechanism is defined. But we did spend a fair amount of time reading and understanding P1393, and in the process we noticed a couple of issues in that paper, and we have offered suggested fixes.
The review team varied some during the effort, but ended up consisting of David Olsen, Ruslan Arutyunyan, Michael J. Voss, and Michał Dominiak, with contributions from the polymorphic executor review team led by Inbal Levi. Michael and Ruslan wrote all the examples. We were assisted in all our efforts by three of the many authors of P0443, Chris Kohlhoff, Daisy Hollman, and Kirk Shoop. The authors answered many questions, provided lots of background information, made useful suggestions, and were generally very helpful. In particular, Chris Kohlhoff’s implementation of the properties mechanism and some other parts of executors that are part of [Asio] (see also here and here) made it much easier to write sample code and try out ideas.
2. Introduction
Properties were originally suggested as a way to simplify the configuration and customization of the
function for executors. At one time during the development of P0443, there were more than a dozen proposed execution functions or customization points, but even that large set covered only a subset of the different ways that execution could happen. The properties mechanism was developed to avoid this explosion in execution functions. Each characteristic of execution was separated into its own property, allowing for independent configuration of that characteristic. The properties mechanism (along with senders/receivers for configuration of returning results) reduced the number of execution functions down to two,
and
. The development of properties is explained in more detail in [P2033].
The properties mechanism was deemed to be useful beyond executors, so it was separated into its own paper, [P1393], and moved from namespace
to namespace
. Properties are not used by the standard library outside of executors at the moment, but that is a possible future direction. A good introduction to properties in general, not specific to executors, is available in Chris’s YouTube video.
3. Examples
We provide a set of examples that begin with a simple use of a standard property and standard executor. We then introduce a custom executor and show how support for a standard property can be added to that executor. We next implement a custom property and show how it can be used with a standard executor as well as our custom executor. Throughout the discussion we also provide example uses of
that wrap both the standard and custom executor and use both standard and custom properties.
All of the examples were compiled against the [Asio] implementation of executors, so the code refers to many things in the
namespace. Simply changing
to
should result in standard-conforming code.
3.1. Using standard properties
The example that follows shows a simple use of
with the executor returned from
. Our example has a race on the variable
if the function objects passed to
execute concurrently -- this is intentional. We include this race purely to introduce an easily demonstrated effect of changing the blocking property; it is not expected that
will be used to avoid races in real applications.
void example_with_stp () { asio :: static_thread_pool stp ( 8 ); // require is ok, since static_thread_pool supports blocking.always auto ex = asio :: require ( stp . executor (), asio :: execution :: blocking . always ); std :: cout << "Required blocking.always" << std :: endl ; // query is ok and informative, since static_thread_pool supports blocking_t if ( asio :: query ( ex , asio :: execution :: blocking ) == asio :: execution :: blocking . possibly ) std :: cout << "ex blocking.possibly" << std :: endl ; int i = 0 ; asio :: execution :: execute ( ex , [ & i ]() { i = 1 ; }); asio :: execution :: execute ( ex , [ & i ]() { if ( i == 1 ) i = 2 ; }); std :: cout << "Before wait i == " << i << std :: endl ; stp . wait (); std :: cout << "After wait i == " << i << std :: endl ; }
Since the executor provided by
supports both
and
for
, we get a deterministic result, where
both before and after the wait on the
context:
Required blocking . always Before wait i == 2 After wait i == 2
3.1.1. Writing a custom executor
It is relatively straightforward to add support for a standard property to a custom executor. Below we show a custom
that has support for querying
and requiring
. Our toy context is built on top of the Threading Building Blocks (TBB) library which is an open-source C++ library for threading. The
maintains a TBB
, which represents a group of tasks that the TBB library will schedule on to its internal thread pool. The
has a member variable
that holds the state of the property, which has a value of
by default. The function
returns the value and the function
returns a new
that has a reference to the same
but with
set to
. The
's member function
calls
to implement a non-blocking
and a calls
to implement a blocking
. The
provides a member function
that blocks until all tasks in its
are complete.
namespace toy { class toy_tbb_context { public : class executor_ { public : constexpr asio :: execution :: blocking_t query ( asio :: execution :: blocking_t ) const noexcept { return blocking_value_ ; } auto require ( asio :: execution :: blocking_t :: always_t val ) const noexcept { return executor_ { context_ , val }; } bool operator == ( const executor_ & other ) const noexcept { return this == & other ; } bool operator != ( const executor_ & other ) const noexcept { return this != & other ; } executor_ ( const executor_ & e ) = default ; ~ executor_ () = default ; template < typename Invocable > void execute ( Invocable && f ) const { if ( blocking_value_ == asio :: execution :: blocking_t :: always ) { context_ . task_group_ . run_and_wait ( std :: forward < Invocable > ( f )); } else { context_ . task_group_ . run ( std :: forward < Invocable > ( f )); } } private : friend toy_tbb_context ; asio :: execution :: blocking_t blocking_value_ ; toy_tbb_context & context_ ; executor_ ( toy_tbb_context & context ) noexcept : context_ ( context ) {} executor_ ( toy_tbb_context & context , const asio :: execution :: blocking_t & blocking_val ) noexcept : context_ ( context ), blocking_value_ ( blocking_val ) {} }; executor_ executor () { return executor_ { * this }; } void wait () { task_group_ . wait (); } private : tbb :: task_group task_group_ ; }; }
We can replace the
in our previous example with our
and, due to its support of
, also get deterministic results, where
both before and after the wait.
void example_with_toy_tbb () { toy :: toy_tbb_context ttc ; // require is ok, since toy_tbb_context supports blocking.always auto ex = asio :: require ( ttc . executor (), asio :: execution :: blocking . always ); // query is ok and informative, since toy_tbb_context supports blocking_t if ( asio :: query ( ex , asio :: execution :: blocking ) == asio :: execution :: blocking . possibly ) std :: cout << "ex blocking.possibly" << std :: endl ; int i = 0 ; asio :: execution :: execute ( ex , [ & i ]() { i = 1 ; }); asio :: execution :: execute ( ex , [ & i ]() { if ( i == 1 ) i = 2 ; }); std :: cout << "Before wait i == " << i << std :: endl ; ttc . wait (); std :: cout << "After wait i == " << i << std :: endl ; }
It should be noted that executors, senders and schedulers are not required to support all of the standard properties. We implemented a
as follows without any support for properties. In this implementation, the
function always calls the non-blocking
.
namespace toy { class toy_tbb_context_no_support { public : class executor_ { public : bool operator == ( const executor_ & other ) const noexcept { return this == & other ; } bool operator != ( const executor_ & other ) const noexcept { return this != & other ; } executor_ ( const executor_ & e ) = default ; ~ executor_ () = default ; template < typename Invocable > void execute ( Invocable && f ) const { context_ . task_group_ . run ( std :: forward < Invocable > ( f )); } private : toy_tbb_context_no_support & context_ ; friend toy_tbb_context_no_support ; executor_ ( toy_tbb_context_no_support & context ) noexcept : context_ ( context ) {} }; executor_ executor () { return executor_ { * this }; } void wait () { task_group_ . wait (); } private : tbb :: task_group task_group_ ; }; }
We can no longer plug this context in directly for
in our example, because the use of
will not compile. However if we are unsure that an executor will provide
for a requirable property, we can use
instead, which will call
only if it is supported; otherwise, it returns an executor that has the same properties established as the executor that was passed to it. It is also intended that all standard properties have reasonable defaults, so
will return an accurate but broad result even if the executor does not explicitly support
. In the case of
, the default
returns
, which is never incorrect. Therefore to use the
, we update our example as shown:
void example_with_toy_tbb_no_support () { toy :: toy_tbb_context_no_support ttc ; // prefer is ok, even for an executor that does not explicitly support blocking_t // it returns an executor with the same properties auto ex = asio :: prefer ( ttc . executor (), asio :: execution :: blocking . always ); std :: cout << "Preferred blocking.always" << std :: endl ; // query is ok, even for an executor that does not explicitly support blocking_t // the result is broad but still useful if ( asio :: query ( ex , asio :: execution :: blocking ) == asio :: execution :: blocking . possibly ) std :: cout << "ex blocking.possibly" << std :: endl ; std :: atomic < int > i = 0 ; asio :: execution :: execute ( ex , [ & i ]() { i = 1 ; }); asio :: execution :: execute ( ex , [ & i ]() { int j = 1 ; i . compare_exchange_strong ( j , 2 ); }); std :: cout << "Before wait i == " << i << std :: endl ; ttc . wait (); std :: cout << "After wait i == " << i << std :: endl ; }
Since, without blocking, our example now contains a real race, we use an
and
. Depending on which operation on
occurs first,
the result after the wait may be 1 or 2. An example output, where the second call to
completes first, and therefore
, is shown below:
Preferred blocking . always ex blocking . possibly Before wait i == 0 After wait i == 1
3.1.2. Using any_executor
We can wrap the executor types returned by
,
and
in the polymorphic wrapper
. For demonstration, we introduce a trivial
that contains the two calls to
as show below. Because the executor types of both
and
support
for
, they can be both be wrapped in
.
using any_exec_type = asio :: execution :: any_executor < asio :: execution :: blocking_t , asio :: execution :: blocking_t :: always_t > ; void algorithm ( any_exec_type ex0 ) { // since blocking_t::always_t is supported, we know we can require it auto ex = asio :: require ( ex0 , asio :: execution :: blocking . always ); std :: cout << "Required blocking.always" << std :: endl ; int i = 0 ; asio :: execution :: execute ( ex , [ & i ]() { i = 1 ; }); // Since it’s blocking, we don’t need the compare_exchange asio :: execution :: execute ( ex , [ & i ]() { if ( i == 1 ) { i = 2 ; }}); std :: cout << "i == " << i << std :: endl ; } void example_with_stp () { asio :: static_thread_pool stp ( 8 ); algorithm ( stp . executor ()); stp . wait (); } void example_with_toy_tbb () { toy :: toy_tbb_context ttc ; algorithm ( ttc . executor ()); ttc . wait (); }
Our
does not, however, support
for
and so it cannot be wrapped with
. If we are unsure if an executor supports
for a property, we can again rely on
and create a different
type that uses the
property adapter.
using any_exec_ptype = asio :: execution :: any_executor < asio :: execution :: blocking_t , asio :: execution :: prefer_only < asio :: execution :: blocking_t :: always_t >> ; void algorithm_with_prefer ( any_exec_ptype ex0 ) { // blocking_t::always_t might be supported, but we don’t know, so prefer auto ex = asio :: prefer ( ex0 , asio :: execution :: blocking . always ); std :: cout << "Preferred blocking.always" << std :: endl ; if ( asio :: query ( ex , asio :: execution :: blocking ) == asio :: execution :: blocking . always ) { int i = 0 ; asio :: execution :: execute ( ex , [ & i ]() { i = 1 ; }); asio :: execution :: execute ( ex , [ & i ]() { if ( i == 1 ) { i = 2 ; }}); std :: cout << "i == " << i << std :: endl ; } else { std :: atomic < int > i = 0 ; asio :: execution :: execute ( ex , [ & i ]() { i = 1 ; }); asio :: execution :: execute ( ex , [ & i ]() { int j = 1 ; i . compare_exchange_strong ( j , 2 ); }); std :: cout << "i == " << i << std :: endl ; } } void example_with_toy_tbb_no_support () { toy :: toy_tbb_context_no_support ttc ; algorithm_with_prefer ( ttc . executor ()); ttc . wait (); }
Since
and
are supported by the executor types of
,
and
,
any of these executors could be passed to our
.
3.2. Using custom properties
It is also fairly straightforward to create a custom property. To demonstrate this, we implemented a
property to control tracing in our
. This property holds a value of true
if tracing is on and false
if tracing is off. We provide a free function
that returns a default value of false, i.e. tracing is off.
namespace toy { struct tracing_t { tracing_t () = default ; constexpr tracing_t ( bool val ) : value_ ( val ) {} template < typename T > static constexpr bool is_applicable_property_v = asio :: execution :: executor < T > ; static constexpr bool is_requirable = true; static constexpr bool is_preferable = true; using polymorphic_query_result_type = bool ; constexpr explicit operator bool () const { return value_ ; } private : bool value_ { false}; }; inline constexpr tracing_t tracing ; template < typename E > constexpr bool query ( const E & , const tracing_t & ) { return false; } }
3.2.1. Modifying our custom executor
The support in our custom
is similar to the support for
. We show only the additions and changes below:
namespace toy { class toy_tbb_context { public : class executor_ { public : // ... constexpr bool query ( const tracing_t & ) const noexcept { return static_cast < bool > ( tracing_value_ ); auto require ( const tracing_t & val ) const noexcept { return executor_ { context_ , blocking_value_ , val }; } // ... template < typename Invocable > void execute ( Invocable && f ) const { if ( tracing_value_ ) ++ context_ . executions_ ; if ( blocking_value_ == asio :: execution :: blocking_t :: always ) { context_ . task_group_ . run_and_wait ( std :: forward < Invocable > ( f )); } else { context_ . task_group_ . run ( std :: forward < Invocable > ( f )); } } private : // ... tracing_t tracing_value_ ; // ... executor_ ( toy_tbb_context & context , const asio :: execution :: blocking_t & blocking_val , const tracing_t & tracing_val ) noexcept : context_ ( context ), blocking_value_ ( blocking_val ), tracing_value_ ( tracing_val ) {} }; // ... int traced_executions () noexcept { return executions_ ; } private : // ... std :: atomic < int > executions_ ; }; }
We can now
this property to turn tracing on and off for the
as shown below:
void example_with_toy_tbb () { toy :: toy_tbb_context ttc ; // require is ok, since toy_tbb_context supports blocking.always auto ex = asio :: require ( ttc . executor (), asio :: execution :: blocking . always ); std :: cout << "Required blocking.always" << std :: endl ; // query is ok and informative, since toy_tbb_context supports blocking_t if ( asio :: query ( ex , asio :: execution :: blocking ) == asio :: execution :: blocking . possibly ) std :: cout << "ex blocking.possibly" << std :: endl ; int i = 0 ; asio :: execution :: execute ( ex , [ & i ]() { i = 1 ; }); asio :: execution :: execute ( ex , [ & i ]() { if ( i == 1 ) i = 2 ; }); ttc . wait (); std :: cout << "i == " << i << std :: endl ; std :: cout << "traced_executions == " << ttc . traced_executions () << std :: endl ; i = 0 ; auto tex = asio :: require ( ex , toy :: tracing_t { true}); std :: cout << "Required toy::tracing_t{true}" << std :: endl ; asio :: execution :: execute ( tex , [ & i ]() { i = 1 ; }); asio :: execution :: execute ( tex , [ & i ]() { if ( i == 1 ) i = 2 ; }); ttc . wait (); std :: cout << "i == " << i << std :: endl ; std :: cout << "traced_executions == " << ttc . traced_executions () << std :: endl ; }
The output of the above code is as follows:
Required blocking . always i == 2 traced_executions == 0 Required toy :: tracing_t { true} i == 2 traced_executions == 2
does not support this property and since it is a standard executor type, we cannot add support. However, we have provided a reasonable default for
, tracing is off. So, we can both
and
this property on the executor type from
, with
returning an executor with the same properties as the executor that was passed to it.
void example_with_stp () { asio :: static_thread_pool stp ( 8 ); // require is ok, since static_thread_pool supports blocking.always auto ex = asio :: require ( stp . executor (), asio :: execution :: blocking . always ); std :: cout << "Required blocking.always" << std :: endl ; // query is ok and informative, since static_thread_pool supports blocking_t if ( asio :: query ( ex , asio :: execution :: blocking ) == asio :: execution :: blocking . possibly ) std :: cout << "ex blocking.possibly" << std :: endl ; int i = 0 ; // prefer is ok, even for an executor that does not explicitly support toy::tracing_t // it returns an executor with the same properties auto tex = asio :: prefer ( ex , toy :: tracing_t { true}); std :: cout << "Preferred toy::tracing_t{true}" << std :: endl ; asio :: execution :: execute ( tex , [ & i ]() { i = 1 ; }); asio :: execution :: execute ( tex , [ & i ]() { if ( i == 1 ) i = 2 ; }); stp . wait (); std :: cout << "i == " << i << std :: endl ; }
3.2.2. Using any_executor
again
Lastly, we can wrap both
and
in
and use both the standard
and the custom
properties. Since
does not explicitly support
, we must again use the
adapter if we want our algorithms to support both executors. In our example, we also demonstrate that implicit conversions support construction of an
from another
that has the same supported properties even if they are in a different order in the template argument list.
using any_exec_type = asio :: execution :: any_executor < asio :: execution :: blocking_t , asio :: execution :: prefer_only < toy :: tracing_t > , asio :: execution :: blocking_t :: always_t > ; using any_almost_same_exec_type = asio :: execution :: any_executor < asio :: execution :: blocking_t , asio :: execution :: blocking_t :: always_t , asio :: execution :: prefer_only < toy :: tracing_t >> ; void algorithm ( any_exec_type ex ) { auto tt_ex = asio :: require ( ex , asio :: execution :: blocking . always ); std :: cout << "Required blocking.always" << std :: endl ; if ( asio :: query ( tt_ex , toy :: tracing )) std :: cout << "Using tracing" << std :: endl ; int i = 0 ; asio :: execution :: execute ( tt_ex , [ & i ]() { i = 1 ; }); asio :: execution :: execute ( tt_ex , [ & i ]() { if ( i == 1 ) i = 2 ; }); std :: cout << "i == " << i << std :: endl ; } void algorithm_tracing ( any_almost_same_exec_type ex ) { auto tracing_exec = asio :: prefer ( ex , toy :: tracing_t { true}); algorithm ( tracing_exec ); } void combined_example () { toy :: toy_tbb_context ttc ; algorithm ( ttc . executor ()); std :: cout << "traced_executions == " << ttc . traced_executions () << std :: endl ; algorithm_tracing ( ttc . executor ()); std :: cout << "traced_executions == " << ttc . traced_executions () << std :: endl ; asio :: static_thread_pool stp ( 4 ); algorithm ( stp . executor ()); std :: cout << "traced_executions == " << ttc . traced_executions () << std :: endl ; algorithm_tracing ( stp . executor ()); std :: cout << "traced_executions == " << ttc . traced_executions () << std :: endl ; }
When run, this example turns on tracing for the
executor when
is called:
Required blocking . always i == 2 traced_executions == 0 Required blocking . always Using tracing i == 2 traced_executions == 2 Required blocking . always i == 2 traced_executions == 2 Required blocking . always i == 2 traced_executions == 2
4. Small Issues
For small issues that have an obvious resolution, mostly wording bugs, we created issues in the Executors GitHub repository. The GitHub issues are listed here, but they can be resolved easily by the authors of P0443 and don’t need to be discussed in LEWG.
-
any_executor section is in the wrong place #488
-
Use of undefined name "Property" in behavioral properties #489
-
Wrong property type in static_thread_pool sender and executor summaries #490
-
Incorrect syntax in description of allocator_t<ProtoAllocator> property #491
-
bad_executor refers to non-existent any_executor::bulk_execute #493
-
S::Ei should be S::Ni #494
-
prefer_only example is out of date #498
5. Issues
Here is a list of issue that we think deserve more discussion in LEWG. Some of the issues have a suggested resolution which we would like LEWG to approve and the P0443 authors to implement. Some issues merely identify a problem that we would like the authors of P0443 to solve, possibly with guidance from LEWG. Any changes made in response to § 5.2 Generic blocking adapter is not implementable or § 5.7 bulk_guarantee specification should match execution policies should also be reviewed by SG1.
5.1. Should properties be usable with non-executors?
All of the properties defined in P0443 have
This limits the property to types that satisfy the
template < class T > static constexpr bool is_applicable_property_v = executor < T > ;
executor
concept. Any attempt to apply the property to something that is not an executor
is normally ill-formed, even if that something has the necessary query
, require
, or prefer
specializations that would otherwise make everything work. For example, the first bullet in the specification of std :: prefer
in P1393 is:
If the
If
is not a well-formed constant expression with value
is_applicable_property_v < T , Prop0 > && Prop0 :: is_preferable true
,is ill-formed.
std :: prefer ( E , P0 , Pn ...)
is_applicable_property_v
check fails, the call to std :: prefer
is ill-formed, rather than proceeding to the fallback of returning E
unchanged that is normally used when a property can’t be applied to a particular executor.
The specification for the
sender types lists member functions that support the properties
,
,
,
,
,
, and
. The specification for the
scheduler types lists member functions that support the properties
and
.
Some senders satisfy the
concept (because
has a fallback that accepts a sender as the first argument). It seems that
's senders satisfy
(because they are noexcept-copyable and equality-comparable), so the property should work with
's senders. But there is no guarantee that other senders satisfy
.
's scheduler types are not guaranteed to satisfy
, so the properties likely cannot be applied to them.
It seems that it would be useful to be able to apply these properties to senders. Senders have access to an executor under the covers. When users get the sender from
and then pass it to
or
or to an asynchronous algorithm, they never have direct access to the executor. Users who want to customize or tune the executor in some way have to go through the sender to get to the executor, but there is no standard way to do that.
Similar reasoning applies to schedulers. Schedulers dole out senders which are associated with executors. It would be helpful for users to tell the scheduler what properties they want for their executors, and the scheduler would only create senders that are associated with executors with those properties.
One possible solution to this discrepancy is to change the
member of each executor property from
to
. That would allow all senders and schedulers to support these properties if desired. Support would still be opt-in; senders and schedulers would have to define the necessary overloads of
,
, and/or
to actually support the properties.
The other way for senders and schedulers to support these properties is to specialize
as described in "Property applicability trait" in [P1393]: "It may be specialized to indicate applicability of a property to a type." Doing it this way can be verbose, requiring a specialization for each supported property by each non-executor class template.
The inconsistency in the use of properties by
's schedulers needs to be dealt with in some way. The three obvious choices are:
-
Remove the support for properties, deleting the
andquery
functions from the scheduler types specification in section 2.5.3. This is not an ideal solution because it removed functionality that we feel is useful.require -
Change the
members of all the relevant properties to have the valueis_applicable_property_v
. There are concerns that this disjunction, while not part of a concept definition, could still increase compile-time unnecessarily.executor < T > || sender < T > || scheduler < T > -
Change the
members of all the relevant properties to have the valueis_applicable_property_v true
. Some members of our group think that using a property defined in the
namespace is enough of an opt-in, and don’t think that properties need to restrict their applicability.execution -
Specify that
is specialized to have a base characteristic ofstd :: is_applicable_property < T , P >
for scheduler types and the relevant properties. This is the most cumbersome solution, but it is the mechanism endorsed by P1393.true_type
The review group agreed that this is an issue that needs to be addressed, but could not agree on the best way to address it. There was some support for all options other than option #1. In the current [Asio] implementation of
, a different approach was taken to solving this issue: the same template class implements the thread pool’s executors, senders, and schedulers. So that class satisfies all three
,
, and
concepts.
Even though properties should already work as specified for
's sender types, it would be good if a similar approach was taken with the sender types. Or at least add a non-normative note pointing out that the properties work because the sender types satisfy the
concept.
5.2. Generic blocking adapter is not implementable
The specification for
in section 2.2.12.2.1 contains:
This customization uses an adapter to implement theproperty.
blocking_adaptation_t :: allowed_t template < class Executor > friend see - below require ( Executor ex , blocking_adaptation_t :: allowed_t );
This allows the
property to be applied to any executor. If the executor does not support the
property directly, then this
function will create a wrapper around the executor with the
property established.
The specification for
in section 2.2.12.1.1 contains:
If the executor has theproperty, this customization uses an adapter to implement the
blocking_adaptation_t :: allowed_t property.
blocking_t :: always_t template < class Executor > friend see - below require ( Executor ex , blocking_t :: always_t );
This allows the
property to be applied to any executor that has the
property. If the executor does not support the
property directly, then this
function will create a wrapper around the executor where calls to
and
will block.
The way these two properties are specified, it is possible to turn any executor into a blocking executor:
// This should work with almost any executor executor auto blocking_ex = std :: require ( std :: require ( ex , blocking_adaptation . allowed ), blocking . always ); static_assert ( std :: query ( blocking_ex , blocking ) == blocking . always );
(It is possible for executor types to prevent these properties from being applied, but it takes extra work on the part of the executor. If executor type
wants to block the
property, it would have to specialize
to be false, or it would have to define a deleted non-member function
to be a better match during overload resolution than the
function defined by
. For executor types that don’t go through this extra effort to block either of these two properties, the double-require example above will compile successfully.)
This is a problem because it is not possible to implement a blocking wrapper around every executor. Without understanding the details of the wrapped executor’s execution context, the wrapper can’t choose a synchronization primitive that will be known to work between the current thread and the wrapped executor’s execution context. For example, using a mutex or a condition variable for synchronization won’t work if the execution context is a fiber or certain GPUs. The standard properties for executors don’t provide enough information about the execution context for the wrapper to know the proper way to block.
A standard construct that makes it easy for users to write code that won’t work is poor design. Changes need to be made to the blocking_adaptation property. Here are some options. Some of them can be combined together.
-
Remove
property; remove the genericblocking_adaptation
adapter. Each executor knows best how to make it a blocking executor. It is not possible to have a general purpose blocking adapter that always works correctly. Converting a non-blocking executor to a blocking executor should be done by the executor itself, by implementingblocking . always
. (If this approach is taken, thestd :: require ( Ex , blocking_t :: always_t )
property should become preferable.blocking . always
should be changed fromblocking_t :: always_t :: is_preferable false
totrue
.) -
Remove the generic
adapter. Theblocking_adaptation . allowed
property would still exist, as would the genericblocking_adaptation
adapter. But there would be no generic adapter for adding theblocking . always
property to an arbitrary executor. Executors where the standardblocking_adaptation . allowed
adapter will work correctly would come with theblocking . allowed
property already established. More unusual executors that won’t work with the standardblocking_adaptation . allowed
adapter won’t support theblocking . always
property at all, and they will either provide their own implementation ofblocking_adaptation . allowed
or not provide any means to become a blocking executor.std :: require ( Ex , blocking_t :: always_t ) -
The
adapter requires a thread-based executor. It is possible to write a generic blocking adapter for executors that operate on standard threads, because many of the standard synchronization primitives are known to work correctly in that situation. To implement this requirement for thread-based executors, change the prerequisite for the blocking adapter to, "If the executor has theblocking . always
property and has either theblocking_adaptation_t :: allowed_t
or themapping_t :: thread_t
property,".mapping_t :: new_thread_t -
Change the name of
and/orblocking_adaptation
to include "thread". Blocking adapters work best with thread-based executors. This could be better communicated to users by include "thread" in the property names, such asblocking
orthread_blocking_adaptation
.thread :: blocking_adaptation
We recommend option #1, which had the support of the majority of the review group.
If the committee feels strongly that a generic adapter for
should exist, then we recommend both option #2 and option #3.
5.3. Non-movable properties
[P1393] does not require properties to be movable or copyable. This was an intentional decision, and not an oversight. While all the properties defined in P0443 are movable and copyable, user-defined executors may define custom properties that are not movable or copyable. The executors framework, such as
, should work correctly with such properties. That means any place that a generic property is passed as a parameter, it must be passed by
and not by value. We have identified these places that should be changed to handle non-movable properties:
In
, the functions
,
, and
accept a generic property by value. They need to be changed to accept it by
. Using
as an example:
template < class Property > any_executor require ( const Property & p ) const ;
(We have heard that the
review group is recommending changing these functions to take the property argument by forwarding reference. We are fine with that, and don’t believe that it would interfere with solving the non-movable property problem.)
's exposition-only
operation uses
to find the appropriate property. This operation won’t find non-movable properties because a non-movable type cannot be converted to itself. The definition should be changed as follows:
In several places in this section the operationis used. All such uses mean the first type
FIND_CONVERTIBLE_PROPERTY ( p , pn ) in the parameter pack
P for which
pn is
std :: is_same_v < p , P > true
oris
std :: is_convertible_v < p , P > true
. If no such typeexists, the operation
P is ill-formed.
FIND_CONVERTIBLE_PROPERTY ( p , pn )
Chris Kohlhoff has tested this and has found these changes to be necessary and sufficient to support non-movable properties in
.
Changing
to work with non-movable properties will be harder. The specification for
lists a public data member of the wrapper property type:
template < class InnerProperty > struct prefer_only { InnerProperty property ;
The data member
should be an exposition-only member, not part of the public interface of the class. (If the public data member is intentional, then
cannot support non-copyable properties.) The
constructor should state that it keeps a copy of the wrapped property if
is copyable, and that it keeps a reference to the wrapped property otherwise.
Even though end users should never have to create
objects (the end user only sees
in the type list for
), the implementation of
likely needs to create temporary
objects. (Asio’s implementation of
creates such temporaries.) Therefore,
's ability to support non-movable properties affects the usability of
.
5.4. Polymorphic executor wrappers and prefer-only properties
The specification of customization point
in [P1393] only checks for functions named
. If requiring the property fails, then
returns the original executor unchanged. The customization point never looks for functions named
.
(
is supposed to return the original object unchanged if requiring the property fails. But a wording bug in P1393 leaves out that fallback action. There needs to be an extra bullet inserted between the fourth and fifth bullets, so it will be third from the end:
This change must be made, even if the other proposed changes in this section are rejected.)
- Otherwise,
if
E .
N == 0
This specification of
makes it difficult for
, or any other generic code that forwards
and
operations on properties, to correctly handle a property that can be preferred but not required.
As an example, assume that some property
defines
and
. Assume that executor type
supports the property
, such that given:
maybe_nice_exec e = ...; auto nice_e = std :: prefer ( e , prefer_nice );
the executor
has the property
.
Now assume that we wrap a
in an
and try to do the same
call:
maybe_nice_exec e = ...; any_executor < prefer_nice_t > wrapped { e }; auto nice_wrapped = std :: prefer ( wrapped , prefer_nice );
Given the current specification of
, this code will not behave as expected.
will not have the
property. The call to
will try to call
's
member function. The specification for that says:
Returns: A polymorphic wrapper whose target is the result ofThe call to, where
std :: require ( e , p ) is the target object of
e .
* this
std :: require ( e , p )
will fail immediately because is_requirable
is false. The call will not be passed on to maybe_nice_exec
to be handled. So the original std :: prefer
call will return the any_executor
unchanged rather than returning a new any_executor
whose target executor has the prefer_nice_t
property.
To get the correct result,
's
function has to call
instead of
when it was called by
. But it doesn’t have any foolproof way to know that it was called by
rather than
.
When generic code such as
is forwarding calls to
and
from one object to a different object, it is not possible to forward the calls correctly because the important information of whether the original call was
or
is lost part way through the process.
The review group recommends that this issue be fixed by changing the specification of
in [P1393], having it check for functions named
first, then for functions named
, then falling back to returning the object unchanged.
The namedenotes a customization point object. The expression
prefer for some subexpressions
std :: prefer ( E , P0 , Pn ...) and
E , and where
P0 represents
Pn ... subexpressions (where
N is 0 or more, and with types
N and
T = decay_t < decltype ( E ) > ) is expression-equivalent to:
Prop0 = decay_t < decltype ( P0 ) >
If
is not a well-formed constant expression with value
is_applicable_property_v < T , Prop0 > && Prop0 :: is_preferable true
,is ill-formed.
std :: prefer ( E , P0 , Pn ...) Otherwise,
if
E and the expression
N == 0 is a well-formed constant expression with value
Prop0 :: template static_query_v < T > == Prop0 :: value () true
.Otherwise,
if
( E ). require ( P0 ) and the expression
N == 0 is a valid expression.
( E ). require ( P0 ) Otherwise,
if
require ( E , P0 ) and the expression
N == 0 is a valid expression with overload resolution performed in a context that does not include the declaration of the
require ( E , P0 ) customization point object.
require - Otherwise,
if
( E ). prefer ( P0 ) and the expression
N == 0 is a valid expression.
( E ). prefer ( P0 ) - Otherwise,
if
prefer ( E , P0 ) and the expression
N == 0 is a valid expression with overload resolution performed in a context that does not include the declaration of the
prefer ( E , P0 ) customization point object.
prefer - Otherwise,
if
E .
N == 0 Otherwise,
if
std :: prefer ( std :: prefer ( E , P0 ), Pn ...) and the expression
N > 0 is a valid expression.
std :: prefer ( std :: prefer ( E , P0 ), Pn ...) Otherwise,
is ill-formed.
std :: prefer ( E , P0 , Pn ...)
Along with this change,
's
function should be changed from a non-member function to a member function. The specification of the
function doesn’t need to otherwise change. (§ 5.3 Non-movable properties and § 5.5 any_executor's FIND_CONVERTIBLE_PROPERTY suggest other changes to
's
function, but those are separate issues.)
Please note that only generic code that wants to correctly forward
and
calls will need to define a
function. The vast majority of executors or other property-aware classes will only need to define
for the properties that they support. That
function will do the right thing for
for all normal property-aware classes.
This is the preferred solution for [Asio], which has already implemented this change.
5.5. any_executor
's FIND_CONVERTIBLE_PROPERTY
The type-erased executor wrapper
defines an exposition-only expression
:
In several places in this section the operationis used. All such uses mean the first type
FIND_CONVERTIBLE_PROPERTY ( p , pn ) in the parameter pack
P for which
pn is
std :: is_convertible_v < p , P > true
. If no such typeexists, the operation
P is ill-formed.
FIND_CONVERTIBLE_PROPERTY ( p , pn )
This exposition-only expression is used in the specification of
's
function:
template < class Property > any_executor require ( Property p ) const ; Remarks: This function shall not participate in overload resolution unless
is well-formed and has the value
FIND_CONVERTIBLE_PROPERTY ( Property , SupportableProperties ) :: is_requirable true
.Returns: A polymorphic wrapper whose target is the result of
, where
std :: require ( e , p ) is the target object of
e .
* this
Consider this example:
any_executor < blocking_t , blocking_t :: never_t > ex { my_executor }; ex . require ( blocking . never );
The
call is ill-formed.
finds
, because it is the first one in the list that is convertible from a
. But
is false, so this particular overload of
doesn’t participate in overload resolution. (This is not a contrived example. This pattern is in the first
example in this paper.)
The fix is to check for
and
at the same time, rather than checking
first and
later. The specification of
's
becomes (with the change from § 5.3 Non-movable properties thrown in):
template < class Property > any_executor require ( const Property & p ) const ; Letbe the first type
FIND_REQUIRABLE_PROPERTY ( p , pn ) in the parameter pack
P for which
pn
is
is_same_v < p , P > true
oris
is_convertible_v < p , P > true
, and
is
P :: is_requirable true
.If no such
exists, the operation
P is ill-formed.
FIND_REQUIRABLE_PROPERTY ( p , pn ) Remarks: This function shall not participate in overload resolution unless
FIND_CONVERTIBLE_PROPERTY
FIND_REQUIRABLE_PROPERTY
( Property , SupportableProperties ) is well-formed
:: is_requirable and has the value.true
Returns: A polymorphic wrapper whose target is the result of
, where
std :: require ( e , p ) is the target object of
e .
* this
A similar change also needs to be made to
's
function. Combining this change with the one from § 5.3 Non-movable properties and changing the function from a non-member to a member as described in § 5.4 Polymorphic executor wrappers and prefer-only properties, the specification becomes:
template < class Property , class ... SupportableProperties > any_executor prefer ( const any_executor < SupportableProperties ... >& e , const Property & p ); Letbe the first type
FIND_PREFERABLE_PROPERTY ( p , pn ) in the parameter pack
P for which
pn
is
is_same_v < p , P > true
oris
is_convertible_v < p , P > true
, and
is
P :: is_preferable true
.If no such
exists, the operation
P is ill-formed.
FIND_PREFERABLE_PROPERTY ( p , pn ) Remarks: This function shall not participate in overload resolution unless
FIND_CONVERTIBLE_PROPERTY
FIND_PREFERABLE_PROPERTY
( Property , SupportableProperties ) is well-formed
:: is_preferable and has the value.true
Returns: A polymorphic wrapper whose target is the result of
, where
std :: prefer ( e , p ) is the target object of
e .
* this
5.6. any_executor < P ... >:: target
causes unused RTTI overhead
The member functions
and
of
cause the generation of RTTI for every executor type that is potentially wrapped in an
. This RTTI overhead is present even if
and
are never called because
is a non-template member function. Some users will likely object to the RTTI overhead as going against C++ principle of "What you don’t use, you don’t pay for."
Using a property rather than a member function to retrieve the target executor from an
reduces the RTTI to situations where the target is actually retrieved. The property can be user-defined and doesn’t have to be part of the
specification:
struct target_property_t { using polymorphic_query_result_type = std :: any ; static constexpr bool is_requirable = false; static constexpr bool is_preferable = false; template < typename Ex > static constexpr bool is_applicable_property_v = std :: execution :: executor < Ex > ; }; template < typename Executor > struct is_polymorphic_executor : std :: false_type {}; template < typename ... Ps > struct is_polymorphic_executor < std :: execution :: any_executor < Ps ... >> : std :: true_type {}; template < typename Executor > inline constexpr bool is_polymorphic_executor_v = is_polymorphic_executor < Executor >:: value ; template < typename Executor > std :: any query ( Executor ex , target_property_t ) { static_assert ( ! is_polymorphic_executor_v < Executor > , "tried to query the target of a polymorphic executor which does not support target_property_t" ); return std :: any ( ex ); }
(This proof of concept, with some example code of how to use it, can be found here.)
If the committee is not overly concerned about the RTTI overhead, the specification can be left unchanged. If the committee likes this approach of using a property to retrieve the target executor, then the
and
members of
should be removed and the target property can either be made a standard property or left to the user to define. The review group is not making any particular recommendation on this matter.
5.7. bulk_guarantee
specification should match execution policies
The specification
in section 2.2.12.5 lists three possible values:
-
: Execution agents within the same bulk execution may be parallelized and vectorized.bulk_guarantee . unsequenced -
: Execution agents within the same bulk execution may not be parallelized.bulk_guarantee . sequenced -
: Execution agents within the same bulk execution may be parallelized.bulk_guarantee . parallel
These correspond in some ways to the execution policies for parallel algorithms, except that there are four execution policies, and
seems to correspond to
rather than
.
The relationship between the
property and the execution policies needs to be better defined. Any differences between them needs to be clearly explained. (At least in P0443, if not in the text of the standard.) The review group believes this is an issue that the authors of P0443 need to address. They are better equipped to come up with the right wording than either the review group or LEWG.
Daisy Hollman thinks it could be helpful to move the specification of
to a separate paper, because a proper treatment is a paper-length topic and any hand-waving could be harmful to eventual success.
5.8. What does "established property" mean?
Many places in the paper use the phrase "property already established" or something similar. But there is no precise definition of what "established" means. The paper should provide a definition, so that implementations (and users who write their own executor types) have a better understanding of what to implement.
Two obvious possible definitions of an established property might be:
-
is true.std :: query ( ex , prop ) == prop_value -
is true (i.e. requiring the property has no effect).std :: require ( ex , prop ) == ex
Both of those definitions have flaws, and don’t work correctly in all situations. We don’t have a good suggestion of what the definition of an established property should be. Like the previous issue, we think this is something that the P0443 authors need to address.
(We have heard that the
review group is also suggesting how to define or reword "established property", at least for its uses in
. There may be overlap in this area.)
5.9. allocator_t :: value
should not be static
Section 2.2.13.1 "
members" states:
static constexpr ProtoAllocator value () const ; Returns: The exposition-only member
.
a_
is
, but the exposition-only member
is not. A static member function cannot access a non-static data member.
We think the correct fix is to change
to be a non-static member function. One purpose of the
property is to get an executor object to use a particular allocator object. The
property needs to store that particular allocator object, so there is not always a generic value that can be returned by a static
function.
5.10. context
property has no constraints
The
property is a query-only property that can be used to get the execution context for an executor. For example, querying the
of an executor that came from a
will return the
object that created the executor.
The issue is that there are no requirements on the type or the value of the result of querying the
property.
can be any type and doesn’t have to satisfy any particular concept. This makes the
property difficult to use in generic code. It is only really useful in code that knows something about the type of executor that it is working with.
We are not proposing that P0443 be changed to deal with this, since we didn’t think of any way to improve the situation. We are just raising this as an issue to be aware of.