As discussed with Bryce and David.
The discussed compromise is that we should replace the enqueue functions with a limited set of lazy task factories. Any syntactic cost of providing a trivial output receiver to these operations can be hidden in wrapper functions. We do not expect a runtime cost assuming we also provide a trivial parameter-dropping receiver in the standard library that the implementation can optimize against.
My passing a full set of parameters to a task construction functions, any task we place in a task graph may be type erased with no loss of efficiency. There may still be some loss of efficiency if the executor is type erased before task construction because the compiler may no longer be able to see from the actual executor into the passed functions. The earlier we perform this operation, however, the more chance there is of making this work effectively.
An executor should either be a sender of a sub executor, or a one way fire and forget entity. These can be implemented in terms of each other, and so can be adapted if necessary, potentially with some loss of information.
We may require or prefer to switch between these.
A SenderExecutor is a Sender and meets the requirements of Sender. The interface of the passed receiver should be noexcept.
| Function | Semantics |
|---|---|
void submit(Receiver<OneWayExecutor::SubExecutorType>) noexcept |
At some future point calls on_value(subExecutor) on the receiver on success, where subExecutor is *this or some subset of *this as useful. At some future point calls on_error(ErrorType on failure. |
ExecutorType executor() noexcept |
Returns an executor. By default, *this. |
submit and executor are required on an executor that has task constructors. Submit is a fundamental sender operation that may be called by a task at any point. To avoid deep recursion, a task may post itself directly onto the underlying executor, giving the executor a chance to pass a new sub executor. For example, if a prior task completes on one thread of a thread pool, the next task may reenqueue rather than running inline, and the thread pool may decide to post that task to a new thread. Hence, at any point in the chain the sub executor passed out of the executor may be utilized.
The passed function may or may not be noexcept. The behavior on an exception escaping the passed task is executor-defined.
| Function | Semantics |
|---|---|
void execute(void(void)) |
At some future point calls the passed callable. |
An executor may provide one of the following task construction functions at a minimum. This minimal set is intended to cover the functionality in P0443.
We may require or prefer an executor with support for any of these task construction functions.
In all cases, an exception that escapes the passed function will be passed to the on_error function of the outgoing receiver.
| Function | Semantics |
|---|---|
Sender<T'> make_value_task(Sender<T>, T'(T)) |
Accepts a sender that passes T or some Sender<T>, returns a Sender that will send T' or some Sender<T'>. Either T or T' may be void. If the Sender calls on_error(E) or done() then those operations will propagate from Sender' |
Sender<T'> make_bulk_value_task(Sender<T>, T'(T), size_t ShF(), S SF(), R RF()) |
Accepts a sender that passes T or some Sender<T>, returns a Sender that will send T' or some Sender<T'>. Either T or T' may be void. If the Sender calls on_error(E) or done() then those operations will propagate from Sender'. Other parameters are as in P0443. |
The type propagated through the graph here will be either T, as defined by the function, or a sender of T. The reason for this is that any executor may choose to wrap its value in some information - that information may be propagation of a sub executor, it may be asynchronous information so that the tasks can be greedily enqueued and run in some other context, out of band of calls to on_value. At any point the passed Sender<T> can take a receiver of T to collapse the Sender back into a value, providing compatibility with an arbitrary receiver and a terminal point for asynchronous work chaining.
In the table below, where a future is returned this is represented by the promise end of a future/promise pair in the compromise version. Any necessary synchronization or storage is under the control of that promise/future pair, rather than the executor, which will often allow an entirely synchronization-free structure.
| Before | After |
|---|---|
executor.execute(f) |
executor.execute(f) |
executor.execute(f) |
executor.make_value_task(NoneSender{}, f).submit(SinkReceiver{}) |
fut = executor.two_way_execute(f) |
executor.make_value_task(NoneSender{}, f).submit(futPromise) |
fut' = executor.then_execute(f, fut) |
executor.make_value_task(fut, f).submit(fut'Promise) |
executor.bulk_execute(f, shf, sf) |
executor.make_bulk_value_task(NoneSender{}, f, shf, sf, [](){}).submit(SinkReceiver{}) |
fut = executor.bulk_twoway_execute(f, shf, sf, rf) |
executor.make_bulk_value_task(NoneSender{}, f, shf, sf, rf).submit(futPromise{}) |
fut' = executor.bulk_then_execute(f, shf, sf, rf, fut') |
executor.make_bulk_value_task(fut, f, shf, sf, rf).submit(fut'Promise{}) |
If a constructed task is type erased, then it may benefit from custom overloads for known trivial receiver types to optimize. If a constructed task is not type erased then the outgoing receiver will trivially inline.
We do not expect any performance difference between the two forms of the one way execute definition. The task factory-based design gives the opportunity for the application to provide a well-defined output channel for exceptions. For example, the provided output receiver could be an interface to a lock-free exception list if per-request exceptions are not required.