Message Passing with Channels

When two threads need to cooperate, you have two basic strategies: let them share memory and protect it with locks, or let them pass messages to each other and never share the underlying data directly. Rust’s standard library favors the second approach with channels — a thread-safe pipe with a sending end and a receiving end. Because sending a value through a channel moves it, the compiler guarantees that only one thread ever owns the data at a time, which rules out data races on that value without a single lock. This lesson covers the std::sync::mpsc channel type from the ground up: the mental model, the syntax, worked examples, and the mistakes almost everyone hits the first time.

Overview: How Channels Work

Think of a channel as a pipe with two distinct ends. Calling mpsc::channel() gives you back a tuple (tx, rx): tx is a Sender<T>, the end you write into, and rx is a Receiver<T>, the end you read from. The name mpsc stands for multiple producer, single consumer: you can clone Sender<T> as many times as you like to give several threads a way to send, but there is only ever one Receiver<T> and it cannot be cloned.

The key idea to internalize is that sending a value is exactly like passing it by value anywhere else in Rust: it moves. If you call tx.send(value), the variable value is consumed — its ownership transfers into the channel, and then to whichever thread eventually calls rx.recv(). This is why channels compose so naturally with ownership: there is never a moment where two threads hold a reference to the same piece of data, so there is nothing for the borrow checker or a data race to fight over. Contrast this with shared-memory concurrency (covered in the Mutex and Arc lessons), where the same memory is genuinely accessed from multiple threads and needs a lock to stay safe.

Receiving works two ways. rx.recv() blocks the calling thread until a value arrives, returning Result<T, RecvError>. If every clone of the Sender has been dropped and no message is coming, recv() returns Err immediately instead of blocking forever — this is how a channel signals it is closed. There is also a non-blocking try_recv(), which returns Err(TryRecvError::Empty) right away if nothing is ready yet. Finally, Receiver<T> implements IntoIterator, so you can write for value in rx to keep receiving values until the channel closes — this is usually the cleanest way to drain a channel.

Syntax

The general shape of working with a channel looks like this:

let (tx, rx) = mpsc::channel();

tx.send(value).unwrap();          // moves `value` into the channel
let received = rx.recv().unwrap(); // blocks until a value arrives

for value in rx {
    // iterate until every Sender is dropped and the channel closes
}
Item What it does
mpsc::channel() Creates an unbounded channel; returns (Sender<T>, Receiver<T>).
mpsc::sync_channel(n) Creates a bounded channel that holds at most n messages; send blocks once it is full, giving you backpressure.
Sender<T>::send(value) Moves value into the channel; returns Err only if the Receiver has been dropped.
Sender<T>::clone() Creates another handle to the same channel so multiple threads can send.
Receiver<T>::recv() Blocks until a value arrives or every Sender is dropped (then returns Err).
Receiver<T>::try_recv() Returns immediately, Ok(value) or Err if nothing is ready.
for x in rx Iterates, blocking between items, until the channel closes.

Examples

Example 1: A single message from a spawned thread

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let message = String::from("hello from the spawned thread");
        tx.send(message).unwrap();
    });

    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}

Output:

Got: hello from the spawned thread

The move closure takes ownership of tx, so the sender lives inside the spawned thread. That thread builds a String, then send moves it into the channel — the local variable message is no longer usable after that line inside the closure. Meanwhile, the main thread calls rx.recv(), which blocks until the spawned thread’s send makes a value available, then prints it.

Example 2: Sending several messages and iterating the receiver

use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let messages = vec![
            String::from("one"),
            String::from("two"),
            String::from("three"),
        ];

        for msg in messages {
            tx.send(msg).unwrap();
            thread::sleep(Duration::from_millis(10));
        }
    });

    for received in rx {
        println!("Got: {}", received);
    }
}

Output:

Got: one
Got: two
Got: three

Here the spawned thread sends three separate String values, pausing briefly between each. The main thread treats rx as an iterator with for received in rx, printing each value as it arrives. When the spawned thread’s closure finishes, its owned tx is dropped, which closes the channel and ends the for loop automatically — no explicit "stop" signal is needed.

Example 3: Multiple producers with a cloned sender

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    for id in 0..3 {
        let tx_clone = tx.clone();
        thread::spawn(move || {
            tx_clone.send(format!("message from producer {}", id)).unwrap();
        });
    }

    drop(tx);

    let mut received: Vec<String> = rx.iter().collect();
    received.sort();

    for msg in received {
        println!("{}", msg);
    }
}

Output:

message from producer 0
message from producer 1
message from producer 2

Each loop iteration calls tx.clone() to get its own handle to the same channel, then moves that clone into a new thread. Because three threads are racing to send, the arrival order isn’t guaranteed, so the example collects everything into a Vec and sorts it for a deterministic printout. Notice the explicit drop(tx): the original sender is never moved into a thread, so without dropping it manually the channel would never close and rx.iter() would block forever waiting for one more sender that will never send anything.

How It Works Step by Step

Walking through Example 1 in order:

  • mpsc::channel() allocates a shared queue internally and hands back a Sender<String> and a Receiver<String> that both point at it.
  • thread::spawn(move || ...) moves tx into the new thread’s closure; the main thread no longer has access to tx.
  • Inside the new thread, tx.send(message) moves message out of the closure’s local scope and pushes it onto the channel’s internal queue, waking up any thread blocked on recv().
  • Back in the main thread, rx.recv() was already blocked waiting; as soon as the value is pushed, it unblocks, removes the value from the queue, and returns Ok(message).
  • .unwrap() extracts the String from the Result (safe here because we know the sender is still alive when we call it), and println! prints it.

The compiler enforces every step of this at compile time: it knows tx was moved into the closure, so any attempt to use tx again in main after the thread::spawn call is a compile error — not a runtime bug waiting to happen.

Common Mistakes

Mistake 1: Using a value after sending it

send moves its argument. Trying to use that variable afterward doesn’t compile:

use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
    let msg = String::from("hi there");

    tx.send(msg).unwrap();
    println!("still have it: {}", msg); // error: value borrowed here after move

    let _ = rx.recv();
}

The fix is to decide whether you actually need the value afterward. If you do, clone it before sending — sending the clone leaves the original untouched:

use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
    let msg = String::from("hi there");

    tx.send(msg.clone()).unwrap();
    println!("still have it: {}", msg);

    let received = rx.recv().unwrap();
    println!("received: {}", received);
}

Output:

still have it: hi there
received: hi there

Mistake 2: Reusing a sender that was already moved into a thread

Each thread::spawn(move || ...) call takes full ownership of everything the closure captures. If two closures both try to capture the same tx, the second one fails to compile because tx was already moved into the first:

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        tx.send(1).unwrap();
    });

    thread::spawn(move || {
        tx.send(2).unwrap(); // error: use of moved value `tx`
    });

    for received in rx {
        println!("Got: {}", received);
    }
}

The fix, as in Example 3, is to clone() the sender once per producer before moving each clone into its own thread:

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();
    let tx2 = tx.clone();

    thread::spawn(move || {
        tx.send(1).unwrap();
    });

    thread::spawn(move || {
        tx2.send(2).unwrap();
    });

    let mut received: Vec<i32> = rx.iter().collect();
    received.sort();

    for value in received {
        println!("Got: {}", value);
    }
}

Output:

Got: 1
Got: 2

Mistake 3: Calling .unwrap() on recv() without expecting a closed channel

A closed channel (every Sender dropped with nothing sent) is a completely normal condition, not a bug — but .unwrap() treats it as one. This compiles fine, but panics the instant it runs, before printing anything:

use std::sync::mpsc;

fn main() {
    let (tx, rx): (mpsc::Sender<i32>, mpsc::Receiver<i32>) = mpsc::channel();
    drop(tx);

    let value = rx.recv().unwrap();
    println!("{}", value);
}

Output:

thread 'main' panicked at ...: called `Result::unwrap()` on an `Err` value: RecvError

Handling the Result explicitly turns a panic into ordinary control flow:

use std::sync::mpsc;

fn main() {
    let (tx, rx): (mpsc::Sender<i32>, mpsc::Receiver<i32>) = mpsc::channel();
    drop(tx);

    match rx.recv() {
        Ok(value) => println!("Got: {}", value),
        Err(_) => println!("channel closed with no message"),
    }
}

Output:

channel closed with no message

Best Practices

  • Reach for channels when the pattern is naturally "producer computes a value, consumer uses it" — it’s usually simpler and safer than sharing memory behind a Mutex.
  • Prefer for value in rx over manually looping on try_recv(); it blocks efficiently and stops automatically when the channel closes.
  • Clone the Sender once per producer thread; never try to move the same Sender into more than one closure.
  • Make sure every Sender handle, including the original, either gets moved into a thread or is explicitly dropped — a forgotten live Sender is the most common reason a receiving loop hangs forever.
  • Handle recv()‘s Result with match or if let in real code; a closed channel is an expected event, not a reason to panic.
  • For large payloads, consider sending a Box<T> or an Arc<T> instead of the value itself, so a big struct isn’t copied through the queue.
  • If producers can outpace the consumer and you want to cap memory use, use mpsc::sync_channel(n) instead of the unbounded mpsc::channel() — it makes send block once the buffer is full.

Practice Exercises

  • Spawn five threads, each sending its own index (0 through 4) doubled through a channel. Collect all five results in the main thread and print their sum. Expected output: Sum: 20.
  • Rewrite Example 2 so the spawned thread sends integers 1 through 5 instead of strings, and have the main thread print only the even values it receives.
  • Build a tiny "request/response" program using two channels: a worker thread receives numbers on one channel, squares them, and sends the results back on a second channel to the main thread.

Summary

  • A channel is a pipe split into a Sender<T> and a Receiver<T>, created together by mpsc::channel().
  • send moves its argument into the channel, so ownership transfers from the sending thread to whichever thread eventually receives it — this is what keeps message passing free of data races.
  • mpsc means multiple producer, single consumer: clone Sender for extra producers, but there is only ever one Receiver.
  • recv() blocks until a value arrives or every Sender is dropped, in which case it returns Err; iterating for x in rx stops automatically when the channel closes.
  • A common bug is leaving a live Sender around (not moved into a thread, not dropped) — the receiver’s loop or recv() call then waits forever.
  • Prefer match/if let over .unwrap() when receiving, and use bounded sync_channel when you need backpressure.