
Description
It's time to port crossbeam-channel
to futures.
Previous discussions:
- Panic on
send
in channels by default? crossbeam-rs/crossbeam#314 - Lessons to be taken from channels in Go? crossbeam-rs/crossbeam-channel#39
- How to know if all the receivers has been dropped? crossbeam-rs/crossbeam-channel#61
cc @matklad @BurntSushi @glaebhoerl
Instead of copying crossbeam-channel
's API directly, I'm thinking perhaps we should design async channels a bit differently.
In our previous discussions, we figured that dropped receivers should disconnect the channel and make send operations fail for the following reason. If a receiver thread panics, the sending side needs a way to stop producing messages and terminate. If dropped receivers disconnect the channel, the sending side will usually panic due to an attempt of sending a message into the disconnected channel.
In Go, sending into a channel is not a fallible operation even if there are no more receivers. That is because Go only uses bounded channels so they will eventually fill up and the sending side will then block on the channel, attempting to send another message into the channel while it's full. Fortunately, Go's scheduler has a deadlock detection mechanism so it will realize the sending side is deadlocked and will thus make the goroutine fail.
In async-std
, we could implement a similar kind of deadlock detection: a task is deadlocked if it's sleeping and there are no more wakers associated with it, or if all tasks are suddenly put to sleep. Therefore, channel disconnection from the receiver side is not such a crucial feature and can simplify the API a lot.
If we were to have only bounded channels and infallible send operations, the API could look like this:
fn new(cap: usize) -> (Sender<T>, Receiver<T>);
struct Sender<T>;
struct Receiver<T>;
impl<T> Sender<T> {
async fn send(&self, msg: T);
}
impl<T> Receiver<T> {
fn try_recv(&self) -> Option<T>;
async fn recv(&self) -> Option<T>;
}
impl<T> Clone for Sender<T> {}
impl<T> Clone for Receiver<T> {}
impl<T> Stream for Receiver<T> {
type Item = Option<T>;
}
This is a very simple and ergonomic API that is easy to learn.
In our previous discussions, we also had the realization that bounded channels are typically more suitable for CSP-based concurrency models, while unbounded channels are a better fit for actor-based concurrency models. Even futures
and tokio
expose the mpsc::channel()
constructor for bounded channels as the "default" and most ergonomic one, while unbounded channels are discouraged with a more verbose API and are presented in the docs sort of as the type of channel we should reach for in more exceptional situations.
Another benefit of the API as presented above is that it is relatively easy to implement and we could have a working implementation very soon.
As for selection, I can imagine having a select
macro similar to the one in the futures
crate that could be used as follows (this example is adapted from our a-chat
tutorial):
loop {
select! {
msg = rx.recv() => stream.write_all(msg.unwrap().as_bytes()).await?;
shutdown.recv() => break,
}
}
What does everyone think?