Message Passing with Channels

There are two great philosophies of concurrency: share memory (with locks, as the previous posts covered) or share nothing and communicate by passing messages. The message-passing school has a famous slogan — "do not communicate by sharing memory; instead, share memory by communicating" — and Rust supports it fully with channels. Instead of multiple threads carefully locking shared state, ownership of data is transferred from one thread to another through a channel, and Rust's ownership system makes that transfer clean and safe.

This post covers the other major concurrency paradigm: message passing via channels. Instead of sharing state (Arc<Mutex<T>>), threads communicate by sending data through channels — transferring ownership of the data. Rust provides channels in std::sync::mpsc, and its ownership model makes message passing especially natural (sending transfers ownership). This post covers channels, sending and receiving, and why message passing is often a cleaner concurrency approach.

The message-passing idea

Message passing is a concurrency approach where threads communicate by sending messages (data) to each other, rather than sharing mutable state. The philosophy:

Message passing — threads communicating by sending data (transferring ownership) rather than sharing mutable state — avoids shared-state complexity and fits Rust’s ownership model perfectly (sending is transferring ownership, a move). It’s an alternative to the Arc<Mutex<T>> shared-state approach. Rust implements it with channels.

Channels: sending and receiving

Rust provides channels in std::sync::mpsc (multiple producer, single consumer). A channel has a transmitter (sender) and a receiver:

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

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

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap(); // send transfers ownership of `val`
        // `val` can no longer be used here — it was moved into the channel
    });

    // Receive blocks until a value arrives.
    let received = rx.recv().unwrap();
    println!("Got: {received}");
}

A channel (mpsc::channel()) gives a transmitter and receiver; tx.send(val) transfers ownership of val through the channel (after which the sender can’t use it — a move), and rx.recv() blocks until a value arrives and gives the receiver ownership. Ownership flows cleanly from sender to receiver, with no shared access. Channels also support receiving a stream of values.

Receiving multiple values

You can send multiple values and receive them by treating the receiver as an iterator — a common, clean pattern:

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

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

    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];
        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_millis(200));
        }
        // when `tx` is dropped (end of thread), the channel closes
    });

    // Iterating over the receiver yields values until the channel closes.
    for received in rx {
        println!("Got: {received}");
    }
}

Receiving multiple values is clean: iterate over the receiver (for received in rx) to get a stream of messages until the channel closes (when the transmitter is dropped), combining message passing with iterators — and tx.clone() allows multiple producers feeding one consumer. This makes stream-of-messages concurrency elegant. Message passing is often the cleaner choice.

Message passing vs shared state

Rust supports both concurrency paradigms — message passing (channels) and shared state (Arc<Mutex<T>>) — and knowing when to use each is practical wisdom:

Rust supports both message passing (channels — transfer ownership, avoid sharing, often simpler, fits pipelines/producer-consumer) and shared state (Arc<Mutex<T>> — for genuinely shared access), both made safe by the ownership/type system — and message passing is often the cleaner default (prefer it where it fits). This completes the concurrency core; next we turn to a different topic — testing in Rust.

Key takeaways

Further reading

Sources & References