When you need to hand off data between threads
You have a main thread generating work items. You spawn a worker thread to process them. The work item is a String, a Vec, or a complex struct. You can't just copy it; that's expensive and defeats the purpose of moving work. You can't share a reference; the worker might outlive the main thread, or the main thread might drop the data while the worker is still using it. You need to hand the data over completely. The sender gives it up. The receiver takes it. That's ownership transfer.
Rust channels solve this. They let you move values across thread boundaries safely. The compiler guarantees that once you send a value, you can't use it again. The receiver gets exclusive ownership. No data races. No double frees. Just a clean handoff.
The channel as a secure drop box
Think of a channel like a secure drop box with two slots. One slot is for the sender. One slot is for the receiver. You put the item in the sender slot. The box locks. You can't take the item back. The receiver opens the other slot and takes the item. The item has moved. The sender no longer has it. The receiver now owns it.
Rust's std::sync::mpsc module provides this mechanism. "mpsc" stands for multiple producer, single consumer. You get a sender (tx) and a receiver (rx). The sender can be cloned and shared across multiple threads. The receiver is usually unique, though you can move it around. Sending a value moves it into the channel's internal buffer. Receiving pulls it out and moves it into your variable.
The compiler enforces the move. If you try to use a value after sending it, the code won't compile. This is the core safety guarantee. The channel is a conduit for ownership, not a window into shared memory.
Minimal example
Here is the simplest case. A channel, a send, a receive.
use std::sync::mpsc;
fn main() {
// Create a channel. tx is the sender, rx is the receiver.
let (tx, rx) = mpsc::channel();
// Create data. We own this String.
let data = String::from("secret payload");
// Send moves the value into the channel.
// 'data' is no longer usable after this line.
tx.send(data).unwrap();
// Receive takes ownership out of the channel.
// 'received' now owns the String.
let received = rx.recv().unwrap();
println!("Got: {}", received);
}
The send call moves data. The variable data is dead. The recv call moves the value out. The variable received owns it. The unwrap calls panic if something goes wrong. In real code, you handle the errors. For learning, unwrap keeps the noise down.
What happens under the hood
When you call tx.send(data), the compiler checks that data is owned. It moves the bits into the channel's internal queue. The original binding is invalidated. If you write println!("{}", data) after the send, you get E0382 (use of moved value). This is good. It prevents you from accidentally using data that no longer belongs to you.
The receiver calls recv. This blocks the current thread until a message arrives. When a message is available, recv moves the value out of the queue and into your variable. The value is now owned by the receiver.
The channel's internal queue is thread-safe. Multiple senders can push to it concurrently. The receiver pulls from it safely. The compiler ensures that only one receiver exists at a time for a given channel, so there's no race on the receiving end.
The compiler moves the value. Trust the move. If you need to keep a copy, clone before you send.
The Send trait gatekeeper
Channels don't accept just any type. They only accept types that implement the Send trait. Send is a marker trait that tells the compiler a type can be transferred safely across thread boundaries. Most types are Send by default. String, Vec, integers, and structs containing Send fields are all Send.
Some types are not Send. Rc<T> is the classic example. Rc uses reference counting without atomic operations. It's fast for single-threaded code, but unsafe to share between threads. If you try to send an Rc, the compiler rejects you with E0277 (the trait Send is not implemented for Rc<T>).
This is a feature, not a bug. The compiler stops you from creating data races. If you need shared ownership across threads, use Arc<T> instead. Arc stands for Atomic Reference Counted. It uses atomic operations for the counter, making it Send.
use std::sync::{mpsc, Arc};
use std::rc::Rc;
fn main() {
let (tx, rx) = mpsc::channel();
// This compiles. Arc is Send.
let arc_data = Arc::new(42);
tx.send(arc_data).unwrap();
// This fails. Rc is not Send.
// let rc_data = Rc::new(42);
// tx.send(rc_data).unwrap(); // Error[E0277]
}
Channels enforce Send. If your type isn't Send, it doesn't cross the thread boundary. Fix the type, don't force the channel.
Realistic pattern: multiple producers
The "mpsc" in mpsc means multiple producers. You can clone the sender and move it into different threads. Each clone can send independently. The receiver gets all messages in order.
use std::sync::mpsc;
use std::thread;
/// Process a work item by sending it to the channel.
fn send_work(tx: mpsc::Sender<String>, item: String) {
// Send the item. Expect panics if receiver is gone.
tx.send(item).expect("Receiver dropped");
}
fn main() {
let (tx, rx) = mpsc::channel();
// Clone the sender for the second thread.
let tx2 = tx.clone();
// Spawn first producer. Move tx into this closure.
thread::spawn(move || {
send_work(tx, String::from("work from thread 1"));
});
// Spawn second producer. Move tx2 into this closure.
thread::spawn(move || {
send_work(tx2, String::from("work from thread 2"));
});
// Main thread acts as consumer.
// recv blocks until a message arrives.
let msg1 = rx.recv().unwrap();
let msg2 = rx.recv().unwrap();
println!("Got: {} and {}", msg1, msg2);
}
The move keyword on the closures is crucial. It forces the closure to take ownership of the variables it captures. Without move, the closure would try to borrow tx, which doesn't live long enough. The move keyword transfers ownership of tx into the thread.
Convention aside: tx.send(val).unwrap() is common in examples. In production code, handle the error. If the receiver is dropped, send returns Err. You might want to log this or stop the sender. Using let _ = tx.send(val) signals that you considered the result and chose to ignore it.
Pitfalls and compiler errors
Channels have a few gotchas. Knowing them saves debugging time.
Receiver dropped. If the receiver is dropped, all senders fail. send returns Err(SendError). If you unwrap the error, your thread panics. Check the result if the receiver might die.
Sender dropped. If all senders are dropped, recv returns Err(RecvError). This signals that no more messages will ever arrive. You can use this to detect when work is done.
Blocking forever. recv blocks until a message arrives. If no sender ever sends, your thread hangs. Use try_recv for non-blocking checks. It returns Ok with the message, Err(Empty) if no message, or Err(Disconnected) if all senders are gone.
Non-Send types. As mentioned, Rc fails with E0277. Raw pointers fail too. You can't send a raw pointer through a channel. The compiler protects you from undefined behavior.
Handle the disconnect. A channel is a conversation. If the other side hangs up, your send fails. Check the result.
Decision: channels vs shared state
Channels move ownership. Shared state shares references. Pick the right tool for the job.
Use std::sync::mpsc when you need a simple, built-in way to move data between threads without external dependencies. It works well for producer-consumer patterns where items are processed once and discarded.
Use crossbeam-channel when you need higher throughput, bounded channels with backpressure, or try-send without allocation overhead. The community prefers crossbeam for performance-critical code. It offers more features and better benchmarks.
Use Arc<Mutex<T>> when multiple threads need to read and write the same data simultaneously, rather than transferring ownership of distinct items. This is for shared mutable state, not message passing.
Use Rc<T> when you need shared ownership within a single thread. It will not compile across threads. Stick to Rc for single-threaded graphs or UI state.
Ownership transfer is the default for channels. If you find yourself wrapping everything in Mutex just to send it, you're fighting the grain. Send the value. Let the receiver own it.