How to use channels for thread communication

Create a channel with mpsc::channel, spawn a thread with the sender, and receive messages on the main thread using recv.

How channels move data between threads

You have a background thread crunching numbers. The main thread is waiting for the result. You can't just share a variable; the borrow checker will scream about mutable references across threads. You need a pipe. A way to pass ownership of data from one thread to another safely.

Channels are Rust's answer. They let you send values from a producer thread to a consumer thread. The data moves through the channel. It doesn't copy. It doesn't share. Ownership transfers cleanly, and the compiler guarantees that only one thread can hold the value at any moment.

The pneumatic tube analogy

Think of a channel like a pneumatic tube system in an old hospital. You have a sender station and a receiver station. You put a canister in the sender, and it travels through the tube to the receiver.

The canister can only be in one place at a time. You can't have two people holding the same canister. When you drop it in the sender, it's gone from your hand. The receiver gets it. Rust channels work the same way. When you call send, the value moves into the channel. The sender loses access. The receiver gets the value. No data races, because the data is moving, not being shared.

Channels also enforce direction. You can't send data back through the same tube. If you need two-way communication, you create two channels. This restriction forces you to think about data flow explicitly. It makes your concurrency model easier to reason about.

Minimal example

Here is the simplest channel setup. A main thread creates a channel, spawns a worker, and waits for a message.

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

fn main() {
    // Create the channel. `tx` is the sender, `rx` is the receiver.
    // They are tied together; dropping one affects the other.
    let (tx, rx) = mpsc::channel();

    // Spawn a thread. We use `move` to take ownership of `tx`.
    // Without `move`, the closure can't capture `tx` because
    // the thread might outlive the main function.
    thread::spawn(move || {
        let message = String::from("hello from thread");
        
        // Send the message. This transfers ownership.
        // `message` is gone from this thread now.
        tx.send(message).unwrap();
    });

    // Block until a message arrives.
    // `recv` returns a `Result`, so we handle the error.
    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}

The compiler enforces the tube. You can't cheat.

What happens under the hood

When you call mpsc::channel(), Rust allocates a buffer on the heap. The tx and rx handles point to this shared buffer. The tx handle implements the Send trait, which means you can move it across thread boundaries. The rx handle usually stays in the main thread.

The mpsc name stands for "multi-producer, single-consumer." You can clone the sender and hand it to multiple threads. Only one receiver exists. This asymmetry simplifies the implementation. The receiver doesn't need to coordinate with other receivers. It just pulls data from the buffer.

When the spawned thread calls tx.send(), the value is moved into the buffer. The main thread calls rx.recv(), which blocks execution until data is available. Once data arrives, it's moved out of the buffer into the main thread. Ownership has transferred cleanly.

If you drop all senders, the channel closes. The receiver detects this. rx.recv() returns an Err. This is how the consumer knows the stream is finished.

Multiple producers

Real code often has multiple workers sending results back. You clone the sender for each thread. The channel stays open as long as at least one sender exists.

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

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

    // Spawn three worker threads.
    for i in 0..3 {
        // Clone the sender for each thread.
        // This increments an internal reference count.
        // The channel stays open as long as one sender exists.
        let tx_clone = tx.clone();

        thread::spawn(move || {
            let msg = format!("Result from worker {}", i);
            tx_clone.send(msg).unwrap();
        });
    }

    // Drop the original sender.
    // This is a convention: the main thread doesn't send,
    // so we drop `tx` to signal that only clones will produce.
    drop(tx);

    // Collect results.
    // `iter` loops until all senders are dropped.
    for received in rx.iter() {
        println!("Received: {}", received);
    }
}

Drop the original sender. It's a signal to the receiver that the work is delegated.

The drop(tx) call is a community convention. It tells readers that the main thread won't send messages. It also ensures that rx.iter() terminates correctly. If you kept tx alive, the iterator would hang forever waiting for a message that never comes.

Backpressure and bounded channels

The mpsc::channel() function creates an unbounded channel. The buffer grows as needed. If you send messages faster than you receive them, memory usage climbs. In a long-running service, this can lead to an out-of-memory crash.

Use mpsc::sync_channel(capacity) when you need backpressure. A synchronous channel has a fixed buffer size. If the buffer is full, send blocks until the receiver makes room. This prevents the producer from overwhelming the consumer.

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

fn main() {
    // Create a bounded channel with capacity 2.
    let (tx, rx) = mpsc::sync_channel(2);

    thread::spawn(move || {
        for i in 0..5 {
            // If the buffer is full, this blocks.
            // The producer waits for the consumer to catch up.
            tx.send(i).unwrap();
            println!("Sent {}", i);
        }
    });

    // Simulate slow consumer.
    for received in rx.iter() {
        thread::sleep(std::time::Duration::from_millis(100));
        println!("Received {}", received);
    }
}

Bounded channels force your threads to pace each other. You get flow control for free.

Pitfalls and compiler errors

Channels are safe, but you can still make mistakes. The compiler catches many of them.

If you try to use a value after sending it, the compiler rejects you with E0382 (use of moved value). The value is gone. It's in the channel. You can't print it or modify it after send.

let val = String::from("data");
tx.send(val).unwrap();
println!("{}", val); // Error E0382: use of moved value

If you forget to check the result of recv, your program might panic. recv returns a Result. If all senders are dropped, it returns Err. Unwrapping that error crashes your thread.

// Bad: panics if channel closes.
let msg = rx.recv().unwrap();

// Good: handles disconnection.
match rx.recv() {
    Ok(msg) => process(msg),
    Err(_) => println!("Channel closed"),
}

Treat the Err from recv as the end of the line. Handle it, or your program hangs.

Another trap is deadlocking with bounded channels. If a thread holds a lock and tries to send on a full channel, and the receiver holds the lock and tries to receive, you get a deadlock. Keep channel operations separate from lock acquisitions.

Non-blocking alternatives

Sometimes you don't want to block. recv blocks until data arrives. try_recv returns immediately. It gives you Ok(value) if data is available, Err(TryRecvError::Empty) if the channel is empty, or Err(TryRecvError::Disconnected) if all senders are gone.

match rx.try_recv() {
    Ok(msg) => println!("Got {}", msg),
    Err(mpsc::TryRecvError::Empty) => println!("No message yet"),
    Err(mpsc::TryRecvError::Disconnected) => println!("All senders gone"),
}

Use try_recv when you have other work to do while waiting. You can poll the channel, do some processing, and poll again. This is useful in game loops or event-driven architectures.

Decision matrix

Pick the right tool for your concurrency pattern.

Use std::sync::mpsc::channel when you need a simple, blocking pipe between threads and want zero dependencies.

Use std::sync::mpsc::sync_channel when you need backpressure to prevent memory growth or to synchronize producer and consumer speeds.

Use crossbeam::channel when you need non-blocking sends, try-receives, or unbounded queues with better performance characteristics than std.

Use Arc<Mutex<T>> when multiple threads need to read and write shared state directly, rather than passing discrete messages.

Use async channels like tokio::sync::mpsc when you are building an async runtime and want to yield control instead of blocking threads.

Use std::sync::mpsc for straightforward producer-consumer flows. Reach for sync_channel when you care about memory bounds. Move to crossbeam or async crates when you need advanced features.

Where to go next