Introduction
After learning Arc and Mutex for shared mutable state, I realized something: most of the time, I didn’t actually need threads to modify the same data. I needed them to communicate — send results, share work, coordinate actions.
This is where channels shine. Instead of fighting the borrow checker to share mutable data across threads, you hand off data from one thread to another through a message queue. It’s a fundamentally different mental model, and once I grasped it, many threading problems became simple.
This post is about that shift. If you’ve read Learning Rust’s Arc and Mutex, this post shows you an alternative approach for many of those same problems.
Why Channels? A Different Approach to Concurrency
Let’s compare two scenarios with the counter problem:
Approach 1: Shared Mutable State (with Arc<Mutex<T>>)
let counter = Arc::new(Mutex::new(0));// Thread 1: lock, increment, unlock// Thread 2: lock, increment, unlock// Result: threads contend for the lockApproach 2: Message Passing (with channels)
let (tx, rx) = mpsc::channel();// Thread 1: compute result, send via channel// Thread 2: compute result, send via channel// Main thread: receive messages one by one, no contentionThe philosophical difference:
- Shared state: “We all access the same data, so we must synchronize.”
- Message passing: “Each thread owns its data, and sends updates to a central receiver.”
Intuition
Think of it like two different office models:
Shared state (Arc<Mutex<T>>): Everyone shares one whiteboard. To write on it, you must wait for others to finish. Lots of contention.
Message passing (channels): Each person has a notebook. When they finish computing something, they send it to a coordinator who collects all results. No contention — they work independently.
Channels are often simpler, faster, and less error-prone than shared state. They’re the preferred approach in Rust for many concurrent patterns.
Note
Rust’s philosophy: “Do not communicate by sharing memory; instead, share memory by communicating.” This is inspired by Go’s concurrency model and reflects the belief that message passing is safer and more composable than shared mutable state.
1. Creating a Channel: mpsc::channel()
Let’s start with the basics. mpsc stands for Multiple Producer, Single Consumer — multiple threads can send messages, but only one thread receives them.
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();This creates two halves:
tx(transmitter/sender) — used to send messagesrx(receiver) — used to receive messages
They’re connected through a bounded queue. Messages sent on tx appear on rx.
That’s it. No runtime, no complicated setup. Just a tuple of two handles.
Note
The type signature is channel::<T>() -> (Sender<T>, Receiver<T>). You usually let type inference figure out T from the first .send() call. If the compiler can’t infer it, specify explicitly: mpsc::channel::<String>().
2. Sending and Receiving: The Happy Path
Sending a Message
use std::sync::mpsc;use std::thread;
let (tx, rx) = mpsc::channel();
thread::spawn(move || { tx.send("Hello from thread!".into()).unwrap();});
let msg = rx.recv().unwrap();println!("Received: {}", msg); // Received: Hello from thread!.send() returns Result<(), SendError<T>>. It fails only if the receiver has been dropped (no one is listening). .unwrap() panics on error; in production, handle it.
.recv() blocks until a message arrives. It returns Result<T, RecvError>. It fails when all senders are dropped (we’ll cover this soon).
Tip
The message is moved from sender to receiver. The thread loses ownership, the main thread gains it. There’s no shared state, no locks, no data races. Ownership handles all the safety.
Non-Blocking Receive: try_recv()
If you don’t want to block waiting for a message, use .try_recv():
use std::sync::mpsc;use std::thread;use std::time::Duration;
let (tx, rx) = mpsc::channel();
thread::spawn(move || { thread::sleep(Duration::from_millis(100)); tx.send("delayed message").unwrap();});
// Non-blocking checkmatch rx.try_recv() { Ok(msg) => println!("Got: {}", msg), Err(mpsc::TryRecvError::Empty) => println!("No message yet"), Err(mpsc::TryRecvError::Disconnected) => println!("Sender dropped"),}.try_recv() returns immediately with Ok(T), TryRecvError::Empty, or TryRecvError::Disconnected.
3. What Happens When All Senders are Dropped?
This is the critical insight that makes channels work.
When you call .recv() and there are no more messages and all senders have been dropped, it returns Err(RecvError). This signals “no more messages will ever come.”
use std::sync::mpsc;use std::thread;
let (tx, rx) = mpsc::channel();
thread::spawn(move || { tx.send(1).unwrap(); tx.send(2).unwrap(); tx.send(3).unwrap(); // tx is dropped here, signal EOF});
// Receive until all senders are droppedlet mut messages = Vec::new();while let Ok(msg) = rx.recv() { messages.push(msg);}println!("Got: {:?}", messages); // Got: [1, 2, 3]The loop runs while senders are alive, and exits when all are dropped.
Intuition
A channel is like a mailbox. You drop letters (send messages). When the mail carrier is done (all senders dropped) and the mailbox is empty (no more messages), the receiver knows “I’m done.”
Without this mechanism, the receiver would wait forever: “Is there another message coming, or is everyone done?” The compiler enforces that all senders are accounted for.
The Common Pattern: for Loop Over Receiver
Rust provides a convenient iterator over the receiver:
use std::sync::mpsc;use std::thread;
let (tx, rx) = mpsc::channel();
thread::spawn(move || { for i in 1..=5 { tx.send(i).unwrap(); } // tx dropped here});
for msg in rx { println!("Got: {}", msg);}The receiver implements IntoIterator, so you can for msg in rx { }. It automatically stops when all senders are dropped.
Tip
This is idiomatic Rust. Use the iterator pattern whenever you want to drain all messages from a channel. It’s cleaner than a while let Ok loop and signals intent clearly.
4. Multiple Producers: Cloning the Sender
The “Multiple” in MPSC becomes useful when multiple threads produce messages:
use std::sync::mpsc;use std::thread;
let (tx, rx) = mpsc::channel();
for i in 0..3 { let tx = tx.clone(); // Clone the sender thread::spawn(move || { let msg = format!("Message from thread {}", i); tx.send(msg).unwrap(); });}
drop(tx); // Drop the original sender
for msg in rx { println!("{}", msg);}Each thread clones the sender, so each has its own copy. When all clones are dropped (including the original), the receiver knows no more messages are coming.
Warning
Critical mistake: If you forget to drop(tx), the receiver will wait forever. Here’s why:
let (tx, rx) = mpsc::channel();
for i in 0..3 { let tx = tx.clone(); thread::spawn(move || { tx.send(format!("Message {}", i)).unwrap(); });}
// Forgot to drop(tx)!
for msg in rx { println!("{}", msg); // Gets 3 messages, then hangs forever}The original tx still exists in the main thread, so the receiver thinks “there might be more messages.” It waits indefinitely.
The rule: If you clone tx, explicitly drop the original after all clones are in use.
Scoped Threads Make This Cleaner
If you’re spawning a fixed number of threads, scoped threads auto-drop the sender when the scope ends:
use std::sync::mpsc;use std::thread;
let (tx, rx) = mpsc::channel();
thread::scope(|s| { for i in 0..3 { let tx = tx.clone(); s.spawn(move || { tx.send(format!("Message {}", i)).unwrap(); }); } // tx is dropped here, scope ensures all threads finish});
// No manual drop neededfor msg in rx { println!("{}", msg);}The borrow checker ensures all threads exit the scope before tx is dropped, so you don’t have to manually manage it.
Tip
Scoped threads + channels is a powerful combination. Scoped threads guarantee all threads finish before the scope exits, so dropping the sender is automatic and safe.
5. Error Handling: When Messages Fail
send() Errors
.send() returns Err(SendError<T>) if the receiver has been dropped:
use std::sync::mpsc;use std::thread;
let (tx, rx) = mpsc::channel();
drop(rx); // Receiver is gone
let result = tx.send("Hello");if let Err(e) = result { println!("Receiver dropped, can't send: {:?}", e);}In practice, you typically .unwrap() or handle it:
tx.send(msg).expect("Receiver should exist");
// Or:if tx.send(msg).is_err() { eprintln!("Failed to send message");}recv() Errors
.recv() returns Err(RecvError) when all senders are dropped:
use std::sync::mpsc;
let (tx, rx) = mpsc::channel::<String>();
drop(tx); // No more messages will ever come
match rx.recv() { Ok(msg) => println!("Got: {}", msg), Err(_) => println!("No more messages (all senders dropped)"),}This is expected behavior, not a fault condition. The receiver uses this to know “I’m done waiting.”
6. Channels vs Mutex: Choosing the Right Tool
Now that you know both patterns, when should you use each?
| Pattern | Best For | Example |
|---|---|---|
Arc, Mutex | Shared mutable state, many readers/writers | Shared counter, cache, configuration |
| Channels | Worker threads producing results | Pipeline, fan-out/fan-in, work distribution |
| Channels | Decoupling producer and consumer speeds | One fast producer, slow consumer |
Arc, Mutex | Coordinating access to a resource | Database connection pool |
| Channels | One-time handoff of data | Thread completes a task, returns result |
Intuition
Arc<Mutex<T>>: “Threads are collaborating on the same piece of data.”
Channels: “Threads are producing independent results and communicating them.”
If threads need to work together on shared data, use a mutex. If threads are independent workers communicating results, use channels.
Quick Decision Tree
- Do threads modify the same shared state? → Use
Arc+Mutex - Do threads compute results independently and send them somewhere? → Use channels
- Do you have many readers, few writers? → Use
Arc+RwLock(reader-writer lock) - Do you have complex coordination? → Consider both, or higher-level abstractions (crossbeam, tokio)
Practical Pattern: Thread Pool Coordinator
Let’s build a real example: a thread pool where worker threads process jobs and send results back:
use std::sync::mpsc;use std::thread;
fn process_jobs(jobs: Vec<i32>, num_workers: usize) -> Vec<i32> { let (tx, rx) = mpsc::channel();
// Distribute jobs to worker threads thread::scope(|s| { for worker_id in 0..num_workers { let tx = tx.clone(); let jobs = jobs.clone(); // In reality, use a shared job queue
s.spawn(move || { for (idx, job) in jobs.iter().enumerate() { if idx % num_workers == worker_id { let result = job * 2; // Simulate work tx.send(result).unwrap(); } } }); } // tx is cloned num_workers times, original dropped here });
// Collect all results rx.into_iter().collect()}
let jobs = vec![1, 2, 3, 4, 5];let results = process_jobs(jobs, 2);println!("Results: {:?}", results); // Results: [2, 4, 6, 8, 10]Here’s what happens:
- Create a channel (one receiver, multiple senders)
- Spawn worker threads, each clones the sender
- Workers process jobs independently and send results through the channel
- Main thread collects results from the receiver
- When scope ends, all senders are dropped, and the receiver stops
No locks, no contention, no synchronization overhead.
Example
This pattern scales to:
- MapReduce: Map workers send intermediate results, reduce collects them
- Pipeline stages: Each stage sends data to the next
- Event broadcasting: Workers send events to a coordinator
- Load balancing: Threads request work from a shared queue
Common Mistakes and How to Avoid Them
Mistake 1: Forgetting to Drop the Original Sender
let (tx, rx) = mpsc::channel();for i in 0..3 { let tx = tx.clone(); thread::spawn(move || { tx.send(i).unwrap(); });}// Oops: original tx not droppedfor msg in rx { println!("{}", msg); // Hangs after 3 messages}Fix: Explicitly drop after all threads are spawned:
for i in 0..3 { let tx = tx.clone(); thread::spawn(move || { tx.send(i).unwrap(); });}drop(tx); // Criticalfor msg in rx { println!("{}", msg); }Or use scoped threads, which auto-drop.
Mistake 2: Ignoring Send/Recv Errors
let (tx, rx) = mpsc::channel();tx.send("message").unwrap(); // What if receiver is gone?let msg = rx.recv().unwrap(); // What if sender is gone?In production, handle errors:
match tx.send("message") { Ok(_) => println!("Sent"), Err(e) => eprintln!("Receiver dropped: {}", e),}
match rx.recv() { Ok(msg) => println!("Got: {}", msg), Err(_) => println!("All senders dropped"),}Mistake 3: Blocking the Receiver
If the main thread blocks on .recv() and worker threads also try to receive, you might deadlock:
// Worker threadsfor i in 0..3 { thread::spawn(move || { let msg = rx.recv().unwrap(); // All block here });}
// Main thread tries to send — deadlocktx.send("work").unwrap();Fix: Design channels as one-way pipes. Main thread sends, workers receive. Or use separate channels for each direction.
Mistake 4: Using Channels When You Need Shared State
// Wrong: Hammering the channel with status updateslet (tx, rx) = mpsc::channel();for _ in 0..1000 { let tx = tx.clone(); thread::spawn(move || { loop { let progress = compute(); // Work tx.send(progress).unwrap(); // Send progress constantly } });}This creates massive queue overhead. If you need live shared state that many threads update constantly, use Arc<Mutex<T>> or better yet, restructure to batch work.
Under the Hood: How Channels Actually Work
For your understanding (not required for use):
- Channels use an internal queue (MPMC queue, often lock-free).
.send()appends to the queue; if queue is full, blocks (bounded channels) or returns error (unbounded)..recv()pulls from the queue; if empty and senders exist, blocks.- When all senders are dropped,
.recv()returnsErr. - Reference counting tracks sender clones; when count reaches zero, receiver is notified EOF.
Note
By default, mpsc::channel() is unbounded — the queue can grow indefinitely. For bounded queues (backpressure), use sync_channel(capacity). Bounded channels block on .send() if the queue is full, providing natural backpressure.
The Full Picture: Communication Strategies
Rust channels are one of several concurrency patterns:
| Pattern | Use Case |
|---|---|
| Channels | Independent workers producing results |
Arc, Mutex | Shared mutable state |
Arc, RwLock | Shared state, many readers |
| Atomics | Lock-free counters, flags |
| Condvar | Wait for a condition to become true |
| Crossbeam channels | More sophisticated (select, bounded, etc.) |
For learning, channels and mutexes are the foundational patterns. Everything else is a specialization.
Practical Takeaways
Summary
Key insights:
- Channels are for communication. One thread produces, another consumes. No shared mutable state.
- MPSC channels let multiple producers send to one receiver, with automatic EOF detection.
- Clone the sender for each producer. Drop the original to signal EOF.
- Use the iterator pattern (
for msg in rx) for idiomatic message receiving. - Channels vs mutexes: Mutexes for shared state, channels for independent workers.
- Error handling matters: Senders fail if receiver is gone; receivers fail when all senders are dropped.
Start with channels for new concurrent problems. They’re simpler, faster, and less error-prone than shared state. Only reach for Arc and Mutex when you truly need shared mutable state.
References
- The Rust Book — Chapter 16: Message Passing
- Rust Standard Library —
std::sync::mpsc - Crossbeam Channels (more advanced)
- Tokio Channels (for async code)
- Rob Pike’s Concurrency Patterns (Go, but principles apply)