-
Notifications
You must be signed in to change notification settings - Fork 30
Lessons to be taken from channels in Go? #39
Comments
To answer this comment from @danburkert:
Honestly, this seems to be a matter of opinion on which different people strongly disagree. I'm feeling pretty ambivalent here, however.
Go would answer "it's your own fault". I think in Go this is not as much of a problem because all channels are bounded, and most channels are even of zero-capacity. But in Rust we do have unbounded channels, where this is a much important issue to consider. This is in my opinion one of the strongest reasons against the proposed Go-like channels in the original comment.
That makes sense. Adding Currently, the main goal of |
I think I'd like to hear more argumentation about why unidirectionality is a desirable property rather than merely a sensible one. From the link:
Maybe somebody can elaborate on what this means and why? It seems to me that the receiver doing a And as a potential counterpoint to unidirectionality: I'm sure many of us have noticed the symmetry between: impl Sender<T> {
fn send(&self, value: T) -> Result<(), T>;
}
impl Receiver<T> {
fn recv(&self) -> Result<T, ()>; // could be thought of as having `value: ()`!
} It's almost as if these can be generalized to: struct Endpoint<A, B> { ... }
impl Endpoint<A, B> {
fn new() -> (Endpoint<A, B>, Endpoint<B, A>);
fn rendezvous(&self, value: A) -> Result<B, A>;
}
type Sender<T> = Endpoint<T, ()>;
type Receiver<T> = Endpoint<(), T>; Which is what OCaml is thinking about doing. Of course, as I meant to imply with the name there, this only makes sense for 0-buffered channels. But, at least, it suggests to me that bidirectionality can also be a sensible property. |
Unidirectionality is strictly less powerful than bidirectionality, so one can only argue for it from the standpoint of simplicity and ergonomics. Unidirectionality makes selection straightforward. Any operation declared inside a With bidirectional closing, however, we have to introduce a special In one sentence, unidirectionality makes the interface smaller, simpler, and more consistent (in terms of
My understanding is that in Go this is simply considered an antipattern. If any send operation can fail, that means most users will just ignore the error and prefer to panic instead. Basically every use of channels in Rust is littered with Also, signalling to the sender that the receiver is shutting down is not hard to do using a 0-capacity channel anyway, and may lead to cleaner design. At least the Go team believes so. Finally, it's interesting to ponder how one would actually implement channel closing. In Go, each channel is wrapped into a single mutex so there's a Note that "close the channel" is literally just a synonym for "freeze the sending side". Since channel closing is only concerned with the sending side, perhaps only the sending side should be allowed to close? The answer to this question probably depends on whether your mental model of a channel is "a simple concurrent queue" or "like a unix pipe where both sides can signal closing".
While Go doesn't have a way of detecting failure of a send operation, they could've easily implemented this feature (e.g.
This is an interesting example. Indeed, the symmetry is beautiful. However... :) I've done a lot of research on queues and channels in other languages and libraries. They will often boast about performance, features, and so on. The rosy story always falls apart when it comes to select. Go is the only language where channel selection is simple, easy, and just works. Many channel implementations don't have any kind of selection at all! Let's take a look at the The
In the end, he was sold on the feature request and decided to implemented the non-panicking send method. However, it turned out that fitting the concept of non-panicking send into
|
@stjepang It was a joy reading your comments in this thread, and I can definitely identify with your toil. However, when I wrote One thing that came up towards the end of BurntSushi/chan#2 was what to do when a channel send occurs on a non-closed channel that is guaranteed to never be received. |
After thinking for a longer moment about I have used (distributed) queuing systems quite extensively for nigh on 5 years at my previous job: I was actually developing the middleware applications on top of them, which were used among other things to... make sure that the luggage you drop at the departure airport arrives at destination. One of the most important properties of our queuing systems was to never lose an item. A queue which acknowledges an item but fails to deliver it was buggy, plain and simple. Which is why closing from the receiving side is slightly weird. Even if the senders immediately stop sending, what of all the items in flight? To design a queue which does not lose items, the receiving side must "request" a close, and then continue processing until it is guaranteed that no other item will be enqueued. This is easily implemented if the queue is locked for each send/receive, but faster implementations are preferred, and then things become racy. And suddenly, we seem very close to the unidirectionality advocated by Go: the receiver may only signal an intent, the senders decide when to stop. Also, note that an absence of receiver should not necessarily fatal. That is, even if the receiver dies (unexpected data in the queue causes a panic which unwinds the thread), it may be desirable to have the ability to spawn a new receiver to continue processing. Being able to use the current queue (already wired in on the sender side, already containing some non-processed items) is quite desirable then. |
The receiver keeps receiving buffered items until there are no more, at which point further calls to |
I like this idea. So basically when sending into a bounded channel that is full and has no receivers left, rather than waiting forever, we panic with an appropriate message. Sounds good to me! We could do something similar in By the way, I'm sure you know the pains of dealing with poor error messages due to trivial mistakes in macros. Well, you're going to like this. :) An example: select! {
recv(r, msg) => msg,
send(s, 1 + 2) => println!("sent")
default(timeout) => ()
} Error message:
The new macro seems to give a sensible message no matter what kind of mistake I try planting in there, and isn't nitpicky about things like trailing commas. I'm pleasantly surprised to discover that macros can be so friendly at all! Thank you for the comment! This is a very compelling argument in favor of Go-like channels, in my opinion. I find the parts about draining the remaining messages and spawning a new receiver pretty convincing. In the end, reacting to the automatic close signal when all receivers go away is probably not the most robust way of shutting down anyway. I think this is the key point Russ Cox was trying to get across in his comments. I'd just like to add a demonstration of how it's still easy to implement a signal in the backwards direction if one needs it: let (sender, receiver) = channel::unbounded();
let (signal, done) = channel::bounded(0);
// The producer thread.
thread::spawn(move || {
while !done.is_closed() {
select! {
recv(done) => break,
send(sender, generate_message()) => (),
}
}
// Do something with the remaining messages.
// Maybe spawn a new worker thread?
});
// The worker thread.
thread::spawn(move || {
let signal = signal; // Will get dropped when this thread exits.
for msg in receiver {
if process_message(msg).is_err() {
// Something went wrong. Shut down.
break;
}
}
}
This confuses me, too. Perhaps @matthieu-m meant to write the following? Which is why closing from the receiving side is slightly weird. Even if the receivers immediately stop receiving, what of all the items in flight? |
Wow. I had no idea that was possible. 😍 |
@BurntSushi considering that I sent a pull request like 5 months ago that does the same thing for @stjepang after reading all of this discussion and thinking about it, I'm starting to lean towards the opinionated, unidirectional channel interface, even though I would have said it makes sense for the receiver to be able to close the channel. The one question I have with that: what happens if the receiver thread panics? Does the sender just keep adding items to the channel's buffer? As @matthieu-m said, losing the items in flight would not be good, but I'm not sure how you would approach creating a new receiver into the same channel from a dead thread, from an API perspective. If there is a solution, that would be interesting to see. |
Sorry, but with the volume of PRs/emails I get that are blocked personally on me, some of them just fall through the cracks. PRs that touch write-once code that I no longer understand naturally go to the bottom of my list, and are often forgotten about unless someone makes noise. It's just the way it is. |
Perhaps something like this? fn producer() {
let (s, r) = bounded(BUFFER);
let (alive, mut dead) = bounded(0);
let mut t = thread::spawn(consumer(r.clone(), alive));
while !finished() {
let msg = produce_message();
select! {
send(s, msg) => {}
recv(dead, _) => {
let (alive, d) = bounded(0);
dead = d;
t = thread::spawn(consumer(r.clone(), alive));
}
}
}
drop(r);
t.join().ok();
}
fn consumer(r: Receiver<Message>, alive: Sender<()>) {
for msg in r {
consume_message(msg); // may panic
}
} |
I'd like to take a step back and challenge some of the core design decisions in
crossbeam-channel
,std::sync::mpsc
andfutures::sync::mpsc
.Motivation:
select!
forcrossbeam-channel
is hard to get rightThe current
select_loop!
macro is kinda silly (it's a loop, you can't usebreak
/continue
inside it, it causes potentially subtle side effects on every iteration). I'm trying to come up with a new, nicer macro, with fewer surprises, and without the implicit loop.This is what seems like the best solution so far:
And this is how it works:
recv
orsend
cases can complete without blocking, a random one is chosen. The chosen operation is executed and its block (in this example{}
for simplicity) is evaluated.closed
cases are ready (because a channel is closed), it is evaluated.default
case is evaluated.Pros:
Cons:
recv
or asend
case doesn't nudge you into handling theclosed
case. For example, this is in contrast to a bareReceiver::recv
operation, which returns aResult
so the compiler advises you to do something with it (like call.unwrap()
).There were some other different ideas for the macro but I won't go there.
Folks from Servo (more concretely, @SimonSapin and @nox) were cool with this macro idea, although it wasn't the absolute best option for their use case.
@matthieu-m had a good point that the
select!
macro should ideally be exhaustive in the sense that it makes sure you don't forget edge cases like a channel being unexpectedly closed.Now let's see if we can somehow force the user to handle the
closed
case for everyrecv
andsend
. One idea is to changerecv
so that it returns aResult<T, RecvError>
:But what about the
send
case? Here's a try:That'd work and eliminate the need for
closed
case, but it doesn't look very nice. To be fair, users of thisselect!
syntax would typically just raise a panic on 'closed' events:Looks a bit better, but still kind of clumsy.
When does a channel become closed/disconnected?
Depends on the implementation.
std::sync::mpsc
: when either theReceiver
or allSenders
get dropped.crossbeam-channel
: when either allReceiver
s or allSender
s get dropped.futures::sync::mpsc
: when either theReceiver
or allSender
s get dropped, or when you callReceiver::close
.chan
: when allSender
s get dropped.close(sender)
. It is not possible to close from the receiver side.These differences are important. The
chan
crate follows Go's model very closely so they have very similar behavior. The reason why Go allows closing from the sender side is because it follows the principle of unidirectionality.Unidirectionality means that information flows in one direction only. Closing a channel signals to the receivers that no more messages will arrive, ever. Note that even if all receivers get dropped, sending into the channel works as usual. The whole idea is that receiver side cannot signal anything to the sender side!
Why do we want unidirectionality? See this and this comment by Russ Cox for an explanation.
Channels in Go
Channels in Go are quite peculiar. They come with a bunch of rules that seem arbitrary at first sight, but are actually well-thought-out. See
Channel Axioms
andCurious Channels
by Dave Cheney. The controversial blog post titled Go channels are bad and you should feel bad is worth a read, too, despite the title.Here are a few interesting rules:
Sending into a closed channel panics. The idea is that senders have to coordinate among themselves so that the last sender closes the channel.
Receiving from a closed channel returns a 'zero value'. This is like returning
None
, but Go doesn't have sum types.Sending into a
nil
channel blocks forever. This is handy when you want to disable a send operation inside aselect
- just set the sender tonil
!Receiving from a
nil
channel blocks forever. This is useful for the same reason the previous rule is. See this StackOverflow answer for an example.How would we port Go's channels to Rust
Let's solve some of the quirks in Go's channels by using two useful language features of Rust: destructors and sum types.
Keeping the idea of unidirectionality, we change the disconnection behavior: channel gets closed only when all
Sender
s get dropped. That's it. This means sending into a channel cannot panic because having a sender implies the channel is not closed. Next, receiving from a closed channel returns anOption<T>
rather than a 'zero value'. Thechan
crate follows the same design - see here and here.I'm feeling very tempted to redesign
crossbeam-channel
around the philosophy of Go channels:This is beautifully simple:
unwrap
s insender.send(foo).unwrap()
. @BurntSushi is going to like this. :)select!
macro doesn't need theclosed
case.recv
case. Perhaps we might want to change theOption
type toResult
.Some drawbacks:
select!
is not as powerful as before. But it probably doesn't matter since all real-world cases should be covered by this simpler version.Note that we could also accept
Option<Sender<T>>
andOption<Receiver<T>>
in therecv
andsend
cases. That would be equivalent to usingnil
channels in Go.We can get all the benefits of Go channels without its weak spots like accidental panics (e.g. sending into a closed channel), accidental deadlocks (e.g. receiving from a
nil
channel), and incorrect closing (we close automatically when the lastSender
gets dropped).Final words
The simple
Go
-like channel interface currently seems to me to be sitting in some kind of sweet spot of the design space. I've been thinking about this problem for way too long now, constantly switching from one idea to another. In the end, I'm not sure whether this one is the way to go and need your opinion.Any thoughts?
cc @arcnmx - you might be interested in this comment, too.
The text was updated successfully, but these errors were encountered: