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 thetemplate < 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 valueis_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 thenamespace 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 theblocking_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 falsetotrue.) -
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 typeFIND_CONVERTIBLE_PROPERTY ( p , pn ) in the parameter packP for whichpn isstd :: is_same_v < p , P > trueorisstd :: is_convertible_v < p , P > true. If no such typeexists, the operationP 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,
ifE .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, wherestd :: require ( e , p ) is the target object ofe .* 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 expressionprefer for some subexpressionsstd :: prefer ( E , P0 , Pn ...) andE , and whereP0 representsPn ... subexpressions (whereN is 0 or more, and with typesN andT = decay_t < decltype ( E ) > ) is expression-equivalent to:Prop0 = decay_t < decltype ( P0 ) >
If
is not a well-formed constant expression with valueis_applicable_property_v < T , Prop0 > && Prop0 :: is_preferable true,is ill-formed.std :: prefer ( E , P0 , Pn ...) Otherwise,
ifE and the expressionN == 0 is a well-formed constant expression with valueProp0 :: template static_query_v < T > == Prop0 :: value () true.Otherwise,
if( E ). require ( P0 ) and the expressionN == 0 is a valid expression.( E ). require ( P0 ) Otherwise,
ifrequire ( E , P0 ) and the expressionN == 0 is a valid expression with overload resolution performed in a context that does not include the declaration of therequire ( E , P0 ) customization point object.require - Otherwise,
if( E ). prefer ( P0 ) and the expressionN == 0 is a valid expression.( E ). prefer ( P0 ) - Otherwise,
ifprefer ( E , P0 ) and the expressionN == 0 is a valid expression with overload resolution performed in a context that does not include the declaration of theprefer ( E , P0 ) customization point object.prefer - Otherwise,
ifE .N == 0 Otherwise,
ifstd :: prefer ( std :: prefer ( E , P0 ), Pn ...) and the expressionN > 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 typeFIND_CONVERTIBLE_PROPERTY ( p , pn ) in the parameter packP for whichpn isstd :: is_convertible_v < p , P > true. If no such typeexists, the operationP 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 valueFIND_CONVERTIBLE_PROPERTY ( Property , SupportableProperties ) :: is_requirable true.Returns: A polymorphic wrapper whose target is the result of
, wherestd :: require ( e , p ) is the target object ofe .* 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 typeFIND_REQUIRABLE_PROPERTY ( p , pn ) in the parameter packP for whichpn
isis_same_v < p , P > trueorisis_convertible_v < p , P > true, and
isP :: is_requirable true.If no such
exists, the operationP 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.trueReturns: A polymorphic wrapper whose target is the result of
, wherestd :: require ( e , p ) is the target object ofe .* 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 typeFIND_PREFERABLE_PROPERTY ( p , pn ) in the parameter packP for whichpn
isis_same_v < p , P > trueorisis_convertible_v < p , P > true, and
isP :: is_preferable true.If no such
exists, the operationP 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.trueReturns: A polymorphic wrapper whose target is the result of
, wherestd :: prefer ( e , p ) is the target object ofe .* 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.