Document Number: | N4032 |
Date: | 2014-05-23 |
Author: | Anthony
Williams Just Software Solutions Ltd |
Having implemented the concurrency extensions from D3904 from the Issaquah wiki, it is apparent that there are several aspects of the specification which are incomplete, and others which I find undesirable.
This paper attempts to enumerate those aspects, and proposes fixes and suggestions.
While I can understand the need for a concrete executor type that can be passed around, so functions that require executors do not have to be templates, there are various downsides to consider too:
add
function (such as a task handle).std::function
to wrap all the tasks. This prevents
the use of move-only function objects such
as std::packaged_task
.If we instead had an exector concept then we could allow
implementations that handled these scenarios. We could also provide
a type-erased generic_executor_ref
which wrapped an
underlying executor in a concrete type.
Uses that are already templates, such as std::async
could now take the executor type directly as a reference, avoiding
the need for virtual function calls, and potentially avoiding the
use of a std::function
wrapper as well.
If we had a movable_function
template equivalent
to std::function
that only required that the target was
movable rather than copyable, then this would be preferable
to std::function
for passing tasks around.
The use of std::chrono::system_clock
for the timeouts
is odd: std::chrono::steady_clock
should be the natural
choice. However the use of timestamps for scheduling is very
simplistic, and probably meets very few real use cases. Again, the
use of an abstract base class and virtual functions is constraining,
and prevents the use of varied scheduling functions for different
scenarios.
My recommendation is to remove the scheduled_executor
class entirely.
The list of concrete executors is limited, but workable. Those
executors that have an underlying executor should return it via
a generic_executor_ref
rather than
an executor*
, to maintain the concrete interface whilst
allowing flexibility in the type of the underlying executor.
One additional executor type that would be nice to have is a
multiplexing executor that takes a set of underlying executors, and
shares the tasks out between them. This and
the loop_executor
would provide basic building blocks
for a thread pool.
The descriptions of the concrete executors are insufficiently detailed when it comes to the scheduling properties. We need wording to specify when the tasks are destructed, and any synchronization relationships. For those executors that use multiple threads we need to specify additional details about which threads the tasks are run on.
std::async
Rather than taking a reference to the executor
base
class, the overloads of std::async
should instead just
take a reference to anything that implements the Executor
requirements. This will require careful wording of the form "shall
only partipate in overload resolution if..." for the various
overloads to avoid ambiguity.
then()
Rather than taking a reference to the executor
base
class, the overloads of then()
should instead just take
a reference to anything that implements the Executor
requirements. Since then()
only takes a nullary
callable, and not an argument set, this is unambiguous.
unwrap()
and invalid futuresThe future returned from unwrap()
is specified to be
valid, regardless of whether or not the inner future is
valid. However, if the outer future becomes ready with an inner
future that is not valid, it is unspecified what the behaviour of
the returned future should be. The inner future can never become
ready (because it has no shared state), but there is now no way to
detect this.
I therefore propose that if the inner future is not valid, the
outer future becomes ready with an exception of
type std::future_error
, with an error code
of std::future_errc::broken_promise
.
unwrap()
and std::shared_future
Firstly, std::shared_future<std::shared_future<R>>::unwrap()
is specified to return a std::future<R>
. I
believe it should return a std::shared_future<R>
,
which can therefore reference the same shared state as the original
inner future, rather than copying the value.
Secondly, std::shared_future<std::future<R>>::unwrap()
is specified to move the inner future into the result:
When the inner future is ready, its value (or exception) is moved to the shared state of the returned future.
This moves the result out from under any other copies of
that std::shared_future
object which refer to the same
result — in theory multiple threads could each have their
own std::shared_future
object referencing the same
inner std::future
, and each could
call unwrap()
in parallel. This results in a race
condition, which I believe is undesirable.
Instead, I propose
that std::shared_future<std::future<R>>::unwrap()
is not permitted. Users should either unwrap
the std::future<std::future<R>>
before it
is converted to a std::shared_future
, or use
a std::shared_future<std::shared_future<R>>
,
which can be safely unwrapped into
a std::shared_future<R>
.
when_all
effectsThe effects as specified are:
Eachfuture
andshared_future
is waited upon and then copied into the collection of the output (returned)future
, maintaining the order of the futures in the input collection.
This implies that the futures are waited on before the call
to when_all
returns, which would defeat the purpose of
the call.
It also requires copying of std::future
objects, which is not possible.
I propose that the wording is clarified to say that the waiting is done in the background, and that the futures are moved.
when_any
effectsThe effects as specified are:
Eachfuture
andshared_future
is waited upon. When at least one is ready, all the futures are copied into the collection of the output (returned)future
, maintaining the order of the futures in the input collection.
This implies that the futures are waited on before the call
to when_any
returns, which would defeat the purpose of
the call.
It also requires copying of std::future
objects, which is not possible.
I propose that the wording is clarified to say that the waiting is done in the background, and that the futures are moved.
when_all
and deferred tasksFutures that result from a call to std::async
, or a
call to then()
on another future can refer to deferred
tasks. It is currently unspecified what happens when such futures are
passed to when_all()
.
Passing these to when_all
should execute the deferred
tasks before the call to when_all
returns, just
as then()
does, as otherwise the future returned
from when_all
will never become ready, as the
deferred tasks will not execute until their futures are waited
on.
when_any
and deferred tasksFutures that result from a call to std::async
, or a
call to then()
on another future can refer to deferred
tasks. It is currently unspecified what happens when such futures are
passed to when_any()
.
Whereas when_all()
requires all the futures to
be ready before the result
is ready, when_any
only requires one of the
supplied futures to be ready. I therefore propose that a
call to when_any
checks the passed futures in the order
passed to see if they are already ready, or are deferred. If future
fi
is ready then the result
is ready, and no further futures are checked. If
future fi
is deferred, then the deferred
task is executed, the result is ready, and no further
futures are checked.
What follows is partial wording for addressing some of the issues raised in this paper.
Remove the executor
and scheduled_executor
classes (2.2.1 and
2.2.2). Replace them with an executor concept
and generic_executor_ref
class defined as follows:
2.2.x Requirements for Executor types
An Executor type is a class that manages the scheduling and execution of supplied tasks. The details of the scheduling and ordering of the tasks, along with the execution agents used to execute the tasks will vary between executors. In order for a type
E
to qualify as an Executor type, the following expressions must be supported, with the specified semantics, wheree
denotes a value of typeE
, andf
denotes a value of a callable typeF
such thatf()
is well-formed, andF
is CopyConstructible.
e.add(f)
- Effects:
- A copy of
f
is constructed in internal storage as ifF g(f)
. The copy off
is executed at the time and manner specified for typeE
.- Throws:
- Any exception thrown by the copy constructor of
f
.std::bad_alloc
if sufficient internal storage cannot be allocated. Any other exceptions specified byE
.- Synchronization:
- The completion of the copy-construction of
f
into internal storage synchronizes-with the start of the execution of that copy.
e.num_pending_closures()
- Returns:
- A value implicitly convertible to
size_t
which is the number of tasks submitted to the executor but not yet started.2.2.y Class
generic_executor_ref
generic_executor_ref
satisfies the Executor requirements (2.2.x). It wraps a reference to a concrete executor type.class generic_executor_ref { public: template<typename E> generic_executor_ref(E& e) noexcept; generic_executor_ref(generic_executor_ref const& other) noexcept; generic_executor_ref& operator=(generic_executor_ref const& other) noexcept; void add(std::function<void()> f); size_t num_pending_closures() const; };
template<typename E> generic_executor_ref(E& e) noexcept;
- Requires:
E
shall satisfy the Executor requirements (2.2.x).- Effects:
- Constructs a new instance of
generic_executor_ref
that refers toe
.
generic_executor_ref(generic_executor_ref const& other) noexcept;
- Effects:
- Constructs a new instance of
generic_executor_ref
that refers to the same underlying executor asother
.
generic_executor_ref& operator=(generic_executor_ref const& other) noexcept;
- Postconditions:
*this
refers to the same underlying executor asother
.- Returns:
*this
void add(std::function<void()> f);
- Effects:
e.add(f)
, wheree
is the underlying executor referred to by*this
.
size_t num_pending_closures() const;
- Returns:
e.num_pending_closures()
, wheree
is the underlying executor referred to by*this
.
Modify the effects clause of 3.4 (when_all
):
- Effects:
Eachfuture
andshared_future
is waited upon and then copied into the collection of the output (returned) future, maintaining the order of the futures in the input collection.- If any of the futures supplied to a call to
when_all
refer to deferred tasks that have not started execution, those tasks are executed before the call towhen_all
returns. Once all such tasks have been executed, the call towhen_all
returns immediately.- The call to
when_all
does not wait for non-deferred tasks, or deferred tasks that have already started executing elsewhere, to complete before returning.- Once all the futures supplied to the call to
when_all
are ready, the futures are moved into the associated state of the future returned from the call towhen_all
, preserving the order of the futures supplied towhen_all
. That future is then ready.- The future returned by
when_all
will not throw an exception, but the futures held in the output collection may.
Modify the effects clause of 3.5 (when_any
):
- Effects:
Eachfuture
andshared_future
is waited upon. When at least one is ready, all the futures are copied into the collection of the output (returned) future, maintaining the order of the futures in the input collection.- Each of the futures supplied to
when_any
is checked in the order supplied. If a given future is ready, then no further futures are checked, and the call towhen_any
returns immediately. If a given future refers to a deferred task that has not yet started execution, then no further futures are checked, that task is executed, and the call towhen_any
then returns immediately.- The call to
when_any
does not wait for non-deferred tasks, or deferred tasks that have already started executing elsewhere, to complete before returning.- Once at least one of the futures supplied to the call to
when_any
are ready, the futures are moved into the associated state of the future returned from the call towhen_any
, preserving the order of the futures supplied towhen_any
. That future is then ready.- The future returned by
when_any
will not throw an exception, but the futures held in the output collection may.