Why Are std::sync::mpsc::channel Senders the Way They Are?

My understanding of senders created through channel() is that (and this will be important later) the compiler determines the type of the content being sent over the channel on the first use of send in the source code. Fine, ok, strongly typed language prefers determining types at compile time, makes sense, BUT send also transfers ownership of the type which seems… at odds with my understanding of OS implemented channels (like pipes) in both unix and windows.

In OS provided versions of channels, the paradigm is to copy the data into memory managed by the send and recv functions associated with that pipe. Naturally this is shared memory so, as a rule of thumb, it outlives any stack frame referencing it during the lifetime of the program. This is where my cognitive dissonance comes in.

In another language (ex: c/c++) if I create a variable local to a stack frame which in turn is local to a thread, when I “send” that data over a channel, in rust terms, I am always “cloning” that data because that data exists within a stack frame that may not exist by the time the receiver goes to recv that data (which is why data is usually copied for send and recv in OS pipes) but in rust I’m… not doing that? The only thing I can think of where transferring ownership through tx.send() is even close to a zero cost abstraction is if rust is doing some magic where data sent over the channel is preemptively marked by the compiler for its initial allocation to be on the heap and the send call is transferring a reference under the hood (which would explain why the compiler would want to know the type so much, it makes transferring that way much easier) but that still has issues because allocating to the heap instead of the stack is not necessarily zero cost.

A transfer of ownership in rust implies (in this case at least) that I move that data out of my scope and into another scope never for me to operate on again, but if rust is implementing a channel the same way an OS is, and not the aforementioned compiler magic, the data is being copied into the shared memory of the the channel in order to be transferred so it must still exist immediately after that copy in the thread local stack frame after the send so why remove ownership if its still there? Why not just require the the type inferred by send implement clone and take a clone-able reference? Also, if the compiler is removing that data from the stack for some reason (when it doesn’t need to) how is that a zero overhead abstraction?

Of course the first answer in response is “just do a manual clone like tx.send(data.clone() why is this even a question” but that too has issues. The clone function is happening within the context of the stack frame of the function it is being called in, so would the data being cloned not also be copied at least initially onto the thread local stack? And in that case the work of clone is happening twice, once for the clone() call which creates a stack frame local region of memory allocated to that cloned value, and once when send is transferring ownership of that cloned value copying it across ends.

This brings me back to the title: why are channels like this, compiler magic or something else?

1 Like

Since std::sync::mpsc::channel() is generic over the data type T sent via the channel, the compiler can indeed, under most circumstances, infer the type of T by the first call of .send().

The function channel()'s purpose is to create a sender-receiver pair, the former of which can be cloned and the latter not (hence, MP-SC). The main problem this functions solves is to ensure that invariants that represent the connection between the Sender(s) and Receiver are upheld, namely, that the Sender sends is data to the correct Receiver.
This actually just means, that there is some internal pointer within Sender to a thread-safe shared memory in the corresponding Receiver. The external API is just a convenient abstraction around this.

So loosely speaking, what a Sender::send() call here roughly does is to

  1. Acquire a lock on the Receiver's shared data.
  2. Push data of type T to the shared memory buffer of the Receiver.
  3. Release the lock.

In doing so, indeed, ownership is transferred from the Sender's send() method to the Receiver, because this is where the data now lives to be retrieved from the Receiver via recv().

In any case where I send data across channels, some kind of ownership must be transferred, because one thread can always outlive another, so the only references that may be safely transferred are 'static. An exemption to this may be scoped threads, but I am not aware of any special mpsc::channel implementation for transferring data between scoped threads only by reference, so that the transferred data outlives the lifetime of the scope.

1 Like

where I send data across channels, some kind of ownership must be transferred

If the core operation occurring is a copy of data into a region of shared memory why must there necessarily be a transfer of ownership? Is copying data into a new region of memory not definitionally a .clone() operation, and if it is, why not have the signature of send take a reference to a clone-able type instead of always taking ownership?

It is technically a move I guess, which, on bare metal, is a bitwise copy.

Also, some types don't implement Clone, but can be safely moved. E.g. mpsc::Receiver.

If you're coming from C++, you may want to watch this: Logan Smith on moves in C++ and Rust

3 Likes

The data still technically existing in the sender's stackframe does not make it valid to be read/written.

The "copy" performed by moving ownership is different than a clone. Moving a Vec for example will copy a pointer and 2 pointer-sized values, which is essentially free and zero-cost. Cloning it might allocate a new region of memory and copy any amount of memory depending on how many elements the Vec contains.

You can also emulate this by using the current signature of send, just call sender.send(value.clone()) instead of sender.send(value). But if Clone was required you would not be able to get the current behaviour though.

If your plan is to clone directly to the receiver stackframe then that will never work. The sender's stackframe might no longer exist when the receiver receives the clonable reference, which would be invalid at that point.

My point is that, at least for value types, it should. If the data being sent is essentially just a pointer to something on the heap then sure presence is not justification for it to be usable, but if the data is a value type such as a primitive or struct allocated in the stack frame of the function then sending it over a channel should not transfer ownership of that type, it should transfer a copy to the shared memory that the receiver can then copy to its stack at its leisure.

See above.

What you quoted before this was is in reference to the function of the .clone() occuring on the stack frame of the sender in a tx.send(data.clone()) and the fact that this would cause 1 more copy onto the stack than necessary to copy the data being transferred into the shared memory segment of the channel. Apologies if this wasn’t clear.

You alread got a couple of good answers, however there is one more thing I would like to share. A Jon Gjengset's Crust of Rust: Channels video, where he implements a very simple version of a channel and explains in-depth how they work. I found "Crust of Rust" series fantastic source when I was learning Rust, due to Jon explaining very well not only how, but also why. I suspect this can help you understand this problem as well.

1 Like

Do you know about trait Copy?

…I do, but I hadn’t even considered move semantics when I was writing this post.

Wouldn’t it still be more work to do a copy from caller to a send callee stack frame in order to satisfy the ownership constraint than to take reference in the send function signature as in c and copy to the shared memory of the channel through the reference or is that optimized out by the compiler?

It can be optimized out, and I think it will most of the time. (And TBF, if we exclude calling functions from zero cost abstraction, what even is one.)

Stack frame doesn't really exist from a language semantic perspective. And obviously, ownership model doesn't exists at machine code level. So the compiler is free to do whatever as long as the behaviour matches on all levels.

And as for why there's not &own T or &move T yet, you can look at various former discussion... #1617 #1646 IRLO and maybe many more. It's a desired feature but seems has too many design details to settle on.

small note : you can send references through channels if you want too, as long as you can show the receiver will not outlive their lifetimes.

a channel is really just like any function that can be called multiple times. it moves its argument in the channel buffer, and recv moves the arguments out of the channel buffer

here we send a mutable reference to a vec that is on the stack:

use std::sync::mpsc::channel;

fn main() {
    
    let mut v = Vec::new();
    let (sender, receiver) = channel();
    let (sender2, receiver2) = channel();
    sender.send(&mut v).unwrap();
    
    std::thread::scope(move |s| {
        s.spawn(move|| {
            let v_mut = receiver.recv().unwrap();
            v_mut.push("hello from thread 1 !");
            sender2.send(v_mut)
        });
        s.spawn(move|| {
            receiver2.recv().unwrap().push("hello from thread 2 !");
        });
    });
    drop(sender);
    
    dbg!(v);
}

it is usually not very convenient because of lifetime requirements

2 Likes

If the data is a primitive type or a struct that is marked as Copy, then a copy is created and ownership of the copy is transferred.

If you want this behaviour for a struct, which is not Copy, then you clone()

1 Like

note that here you reffered to send(2), which as i understand it is for sockets(please correct me if i am wrong), and thus can talk to other processes. the requirements for such are quite different from channel, which is a wholly intra-process system (which allows it to send things like references to statics, Boxes, and Arcs, even without wonsidering stack references).

to do something like send(2), one would need to either only allow slices of bytes as input or use a trait (like serde::Serialize or binrw::BinWrite) that allows turning a rust value into such a sice of bytes.

that's what UdpSocket::send does in rust.

we do haves pipes in rust. they are not typed (they cannot be, as we can't expect other processes to understand our program's types), and they take &[u8] to write through the io::Write trait, because again, we can't expect other processes to respect ownership rules

1 Like

moves in rust simply mean that the copiler is allowed to copy the data in a new location and invalidate the old one. a channel taking ownership of what you send simply means that it can do a simple memcopy of the data you gave it into the shared memory knowing that this is not going to create unsound behaviour.

if a channel were to copy the data without invalidating the source it would require all objects sent on the channel to be copy

I see that the subject of "move semantics" is raised again and again in this thread. @Schard already linked to a video comparison between move semantics in Rust and C++ in this thread:

But seeing that this subject I still discussed I would also like to share another resource. A blog post from TheCodedMessage - C++ Move Semantics Considered Harmful (Rust is better).

And TL;DR for those who don't want to read it, or watch Logan Smith's videos is that in Rust move is destructive (compiler invalidates move-from value and does not run its destructor) and are always performed by memcpy. In C++ on the other hand moves are non-destructive (each object has to have valid moved-from state, for example std::unique_ptr will set pointer to null, when it is moved from) and moves are defined by move constructors.

Rust approach matches language's ownership semantics. So sending value over a channel to (potentially) different thread is the same as passing it by value to a local function. Value will be moved by memcpy and source will be invalidated.

Where are you getting the idea that channels are (or aim/intended to be) zero cost[1]? (Long time since I read a topic on such matter,) I don't think any such claim has been made. They have been about convenience of getting threaded code that works safely.


  1. (Can't write better performing however you try) ↩︎

2 Likes

My reference of send(2) instead of pipes was mainly because my understanding of sockets was that they were essentially more analogous to the channels of rust in that they are basically just message oriented pipes. Either way the api is largely the same (at least on linux) as both the pipe and socket api is just a write() call to a certain file descriptor.

Thanks for pointing me toward pipe, I didn’t know this existed. It’s helpful to know that the intent behind channels isn’t meant to be 1:1 pipe behavior.

The third page of the rust book indicates zero-cost abstractions were the entire goal of rust, I assumed that extended to all of its abstractions.

You've gotten a few answers in terms of the specific characteristics of an MPSC channel, but there's another explanation that I think is simpler and more direct. In a function call expression,

foo(value)

the result of the argument expression value is moved into the parameter variable inside of foo, regardless of what function foo is. As applied to a sender, that means that

sender.send(foo)?;

must move foo. It doesn't actually matter that it's going into a channel.

If you want to retain ownership of foo following this call, you can pass a reference, instead of passing the value itself:

sender.send(&foo)?;

However, you then need to ensure that foo will live for as long as the reference will, and since a sender makes no guarantees that the receiver will ever discard the result, that effectively means that you can only send references to things that live for 'static lifetimes. (Edit: see @robofinch below.)

1 Like

It looks like mpsc::channel doesn’t impose a 'static requirement, since lifetimes can enforce that a reference must be discarded by the end of the lifetime. It’s thread::spawn that includes a 'static bound.

1 Like