Open tomaszkam opened 4 years ago
I disagree that senders need to be copy constructible in order to be multi-shot. Take for example the simple just
sender, which is a "ready" sender: just(42)
is a sender that sends 42
to the receiver's value channel.
auto answer = just(42);
submit(answer, a_receiver{}); // connect it once and start the operation
submit(answer, a_receiver{}); // connect it a second time and start the operation
The answer
sender isn't copied, but it is connect
-ed multiple times. That works because it has a &
-qualified connect
overload.
What about the value 42
hold in the sender? Aren't they copied into the operation?
What make the sender
not copyable, if you need to essentially copy all of the state from it to the operation, if it is called on l-value?
In other words, what would be example of sender
for which, the following:
submit(s, recv)
Would not be semantically equivalent to:
submit(decay-copy(s), recv)
I am trying to go deep into what being multi-sender means, and because operation may outlive the sender, we need to be able to move the sender state to operation. One-shot allow it to be moved, but multi-sender needs to perform a copy. This should manifest in sender jsut being copyable (as you can copy all of its state).
For the just
sender, it is the case that &
-qualified connect
overload requires that all the value types are copy-constructible. I see nothing wrong with making the sender itself move-only, though. The values are copied, but not the sender itself. I don't know of a generic algorithm that requires copy constructiblility of multi-shot senders. I think this requirement would be an over-constraint.
@lewissbaker, @kirkshoop feel free to chime in here.
I would reverse the question, what would be prohibited by simple model, that just model multiple connect of the same sender
as an optimization connecting a copies of sender
. How it would differ from current semantic?
I think what you're saying is that, due to the semantics of senders and operation states, the salient parts of a multi-shot sender must necessarily be copy constructible, so why not require the sender itself to be copy constructible? That is reasonable, but we generally infer concepts by looking at the algorithms and extracting requirements. This process is iterative and somewhat subjective, and sometimes we permit over-constraint in the interest of keeping the numbers of concepts manageable and the algorithm constraints sensible. That doesn't seem to be the case here, though.
In the absence of algorithms that need the operation, my preference is to leave it out of the concept.
The alternative is to hand-wave about the semantic of the multi-shot connect and describe that call of lvalue
makes a copy without making a copy.
The capability of the sender allows me to perform a simple fork, of execution, where I can copy the partially-constructed sender and just pass it to other thread and invoke it there. Example:
auto sender = ...;
auto s1 = sender | via(sched1);
auto s2 = sender | via(sched2);
Is the above well-formed? Why? Will sender
produce same result for sched1
and sched2
? [ Assuming current context, when the sender cannot be copied. ]
Calling connect
on sender
cannot introduce data races, so nothing prevents via(sched1)
from making sender unusable if modification is done under mutex.
The connect()
function constructs an operation state object. The just()
sender is allowed to constrain support of l-value connect()
on the copy-ability of the value. The just()
sender is allowed to base its own copy-ability on the copy-ability of the value. These are each independent choices.
I can have a sender that is not copyable and is not moveable. This sender can hold a reference to something that is only needed in start() and copy that reference to the operation state during l-value connect()
and use that reference in start()
while keeping the reference in scope by contract (not moveable or copyable).
requiring that connect() can only be l-value called on a sender that is copyable is over-constrained
The
connect()
function constructs an operation state object. Thejust()
sender is allowed to constrain support of l-valueconnect()
on the copy-ability of the value. Thejust()
sender is allowed to base its own copy-ability on the copy-ability of the value. These are each independent choices.
Maybe, but for the paper defined as currently, the effect of calling connect on same sender multiple times are unclear - it will create operation stare, but it is not specified that it will be same, or totally different. Defying l-value connect as semantically equivalent to connect on copy, gives it a clear meaning.
I can have a sender that is not copyable and is not moveable. This sender can hold a reference to something that is only needed in start() and copy that reference to the operation state during l-value
connect()
and use that reference instart()
while keeping the reference in scope by contract (not moveable or copyable).
Why such sender would not be copiable? The reference can be copied. Also, you cannot have not-movable senders (concept require them to be movable), so the referenced object need to be stored outside of sender, which eliminates aby reasons for it not being copiable.
requiring that connect() can only be l-value called on a sender that is copyable is over-constrained
| Why such sender would not be copiable? The reference can be copied.
We use this to prevent the lifetime of the sender from being shifted outside the lifetime of the reference it holds. This is the same reason that operation state is not copyable or moveable.
| Also, you cannot have not-movable senders (concept require them to be movable),
I think this is over-constrained
Another possible interpretation of lvalue connect()
I've been mulling over is that it might allow the operation-state to reference state stored in the sender rather than having to copy/move it into the operation-state, albeit at the expense of requiring that the caller of connect()
keep the sender alive until the operation completes.
The idea behind this approach would be to allow similar implementation strategies to those used by awaitables when they are co_await
ed from a coroutine. e.g. the awaitable is equivalent to sender and it's operator co_await()
is roughly equivalent to connect()
. However, with an awaitable, we can generally assume that the operand to the co_await
expression (ie. the awaitable) will live for the duration of the co_await
expression (ie. until it completes). This allows the awaiter/operation-state returned from operator co_await()
to simply hold a reference to the awaitable, rather than having to copy the state into
For sender/receiver implementations, however, this is potentially error-prone as we don't have the same guarantees about the fusing of connect()
and start()
and of the lifetime of temporaries that we have in a co_await
expression.
The alternative, however, means that when we co_await
a sender from a coroutine, we end up copying/moving state from the sender into the operation_state
(which is stored inside the awaiter) even though we don't need to as the sender itself would have been kept alive anyway.
operation state is not copyable or moveable
The reason the operation-state is not copyable/movable is because, for composed operations, an operation-state's constructor will often construct child operation-states by calling connect()
, passing a receiver that contains a pointer to this
.
For an example, see https://github.com/facebookexperimental/libunifex/blob/c084cb645f1a537632aedf2352016053ca5c78ea/include/unifex/stop_when.hpp#L197
If we were to copy/move an operation-state, we would need to be able to find/update the receiver to have it point to the new operation-state location, which would add a lot of extra overhead for the book-keeping.
One solution for both use cases (referencing internals of the sender), would be to introduce a new wrapper in the form:
sender_ref
/multisender_ref
This would provide both connect
with on the &
, const &
respectively. The implementation of the connect
of such wrapper classes will call connect_ref
if that well-formed, otherwise connect
with &&
,const &
of the sender.
The connect_ref
would be a new CPO, that would allow the implementation of the sender to store a reference to the internals in the operation state instead of making a copy of it. The caller will need to provide a guarantee that the sender will be alive for the execution of the operation, but that will be clearly indicated on call side, by use of sender_ref
- similarly how we use std::ref
to pass callbacks to thread
/algorithms
,
I am suggested a sender_ref
as a wrapper, instead of just new connect_ref
CPO, because that would allow passing such sender to algorithms, without them being aware of possible reference semantics.
This is related to discussion from the LEWG review, where we discussed if I can resue
sender
(via passinglvalue
toconnect
) orreceiver
(via passinglvalue
toconnect
). I believe that you where are correct that reusing receivers is not allowed (outside of the context), but I believe that the same prohibition applies to thesender
- when I passlvalue
sender to theconnect
, I need to make a "copy" of it for the operation.From all of the above, I think we are missing the additional requirement for the
sender
concept, which would imply that anysender
that satisfiescopy_constructible
(syntactic requirement) needs to also model it (`semantic requirement). I.e. we should introduce semantic requirement akin one proposed in Option A of 3453.In other words, the sender needs to be always
movable
(one-shot), or they are also copyable (multi-sender). You can check the later by checking ifcopy_constructible<S>
is true.@ericniebler Does above make sense to you?