Code of the Day
AdvancedConcurrency

Channels

Communicate between threads by sending owned values through std::sync::mpsc channels — the message-passing alternative to shared state.

RustAdvanced11 min read
Recommended first
By the end of this lesson you will be able to:
  • Create a channel with mpsc::channel and split it into Sender and Receiver
  • Send values across a channel and receive them
  • Clone Sender for multiple producer threads
  • Explain the trade-offs between bounded and unbounded channels

Shared state with Mutex requires every thread to agree on locking discipline. offer a different model: threads communicate by sending owned values to each other. The sender gives up ownership of the value; the receiver gains it. There's no shared memory to coordinate — the channel owns data in transit.

This is the "do not communicate by sharing memory; instead, share memory by communicating" philosophy, made explicit in Rust's type system.

std::sync::mpsc

The standard library provides a multi-producer, single-consumer (mpsc) channel:

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

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

    thread::spawn(move || {
        tx.send(String::from("hello from thread")).unwrap();
    });

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

mpsc::channel() returns a (Sender<T>, Receiver<T>) pair. The type T is inferred from the first send. rx.recv() blocks until a message arrives; it returns Result<T, RecvError>, where RecvError means all senders have been dropped.

The channel takes ownership of the sent value. The thread that sends String::from("hello") can no longer use it after send(). The receiver gets exclusive ownership. This is ownership-based message passing — no mutex needed.

Iterating over messages

recv() gives you one message. To drain a channel until it closes, iterate the receiver:

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

thread::spawn(move || {
    for i in 0..5 {
        tx.send(i).unwrap();
    }
    // tx dropped here — channel closes
});

for received in rx {   // iterates until tx is dropped
    println!("{}", received);
}

When all Sender handles are dropped, the channel closes. Iterating rx then terminates naturally — no sentinel value needed.

Multiple producers

The channel is multi-producer: clone the Sender for each producer thread:

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

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

    let handles: Vec<_> = (0..4).map(|i| {
        let tx = tx.clone();   // each thread gets its own Sender
        thread::spawn(move || {
            tx.send(format!("message from thread {}", i)).unwrap();
        })
    }).collect();

    drop(tx);   // drop the original — only clones remain

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

    for h in handles { h.join().unwrap(); }
}

Note the drop(tx) before the iteration loop: if the original tx is still alive, the channel never closes (there's always at least one sender). Dropping it lets the iteration terminate when all clones are gone.

Forgetting to drop the original Sender is a common mistake. If any Sender clone is kept alive longer than intended, the receiver will block forever waiting for messages that never come.

Non-blocking receive

recv() blocks the calling thread. Use try_recv() to check without blocking:

match rx.try_recv() {
    Ok(msg)                          => println!("got: {}", msg),
    Err(mpsc::TryRecvError::Empty)   => println!("no message yet"),
    Err(mpsc::TryRecvError::Disconnected) => println!("all senders dropped"),
}

try_recv is useful in event loops where you don't want to block waiting for work.

Bounded vs unbounded channels

mpsc::channel() creates an unbounded channel — the sender never blocks, and messages accumulate in a queue. If the producer is faster than the consumer, memory grows without bound.

mpsc::sync_channel(n) creates a bounded (synchronous) channel with a buffer of size n:

let (tx, rx) = mpsc::sync_channel(10);  // buffer of 10 messages

With a bounded channel, send() blocks when the buffer is full — providing backpressure. This prevents runaway memory use and is the right default for pipelines where you want the producer to slow down when the consumer can't keep up.

Check your understanding

Knowledge check

  1. 1.
    After calling tx.send(value), can the sending thread still use value?
  2. 2.
    When does iterating over a Receiver terminate?
  3. 3.
    A bounded channel (sync_channel) will block the sender when the buffer is full, providing backpressure.

Do it yourself

Build a pipeline: one producer thread reads numbers 1–20, a worker thread filters odds and sends them to a collector, and the main thread collects and prints the results.

cargo new channel-pipeline
# Edit src/main.rs with two channels: producer -> filter -> main
cargo run

Observe how ownership flows through the pipeline — no locks needed, and no data is copied.

Where to go next

Channels and threads give you straightforward parallelism for CPU-bound work. For IO-bound work — network servers, file operations, many small tasks — async/await is a better fit. Next: an introduction to async Rust and the Tokio runtime.

Finished reading? Mark it complete to track your progress.

On this page