Message Passing Between Threads

How to use Rusts mpsc channels to send data between threads safely, covering ownership transfer, multiple producers, and common pitfalls

A thread that does all the work alone limits what a program can do. Real systems split work across multiple threads, but then those threads need to coordinate. One thread must hand off a result to another, or signal that a task is done, or stream chunks of data as they become available. If threads share memory to do this, they have to synchronize access to that memory — and that quickly becomes the source of subtle, hard-to-reproduce bugs.

Rust offers an alternative model: threads communicate by sending messages, not by reading and writing shared variables directly. The idea is captured by a phrase from Go’s documentation: “Do not communicate by sharing memory; instead, share memory by communicating.” In Rust, the standard library provides channels for exactly this purpose.

What Message Passing Solves

When two threads share a piece of data, every read and write must be carefully guarded so that neither thread sees a half-updated value or steps on the other’s changes. The programmer has to answer: who owns the lock? In what order do we acquire locks? Are we sure no deadlock is possible? A single mistake here can corrupt data, and the error might only appear under specific timing conditions that are nearly impossible to reproduce.

Message passing sidesteps this by giving ownership of the data to the message itself. The sending thread constructs a value and hands it over to the channel; after that, it cannot touch the value again. The receiving thread takes ownership and can do whatever it wants with it. At no point do both threads have simultaneous access. This makes reasoning about the code drastically simpler — and Rust’s ownership system enforces this pattern at compile time.

Creating a Channel with mpsc

Rust’s standard library offers channels through the std::sync::mpsc module. mpsc stands for multiple producer, single consumer. A channel can have many sending ends — cloned from a single transmitter — but only one receiving end. This matches the common pattern where several worker threads produce results and a single collector thread assembles them.

Creating a channel returns a tuple: the transmitter (tx) and the receiver (rx).

use std::sync::mpsc;
fn main() {
    let (tx, rx) = mpsc::channel();
}

This code alone will not compile because the compiler cannot infer what type of data the channel will carry. The moment you call tx.send(...) with a concrete value, the type is locked in. Until then, you can annotate the types explicitly:

use std::sync::mpsc::{self, Sender, Receiver};
fn main() {
    let (tx, rx): (Sender<String>, Receiver<String>) = mpsc::channel();
}

What does MPSC mean?:

Multiple producer, single consumer. Multiple threads can hold clones of the sender and push data into the same channel. Only one receiver exists, consuming all messages in order. This design avoids the need to decide which consumer gets which message — there is only one.

Sending a Single Message Between Threads

The most basic use of a channel is to pass one value from a spawned thread back to the main thread. The spawned thread needs to own the transmitter, so the transmitter must be moved into the closure with the move keyword.

use std::sync::mpsc;
use std::thread;
fn main() {
    let (tx, rx) = mpsc::channel();
    thread::spawn(move || {
        let message = String::from("hi");
        tx.send(message).unwrap();
    });
    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}

When you run this, the main thread blocks on rx.recv() until the spawned thread sends "hi". The output is Got: hi.

The send method returns a Result. It fails if the receiver has already been dropped — meaning no one is listening anymore. In a real application you would handle that error instead of calling unwrap. For a first example, unwrapping is fine because the receiver is still alive.

The recv method also returns a Result. It fails when all senders have been dropped and the channel is empty — signaling that no more messages will ever arrive. This is how a receiver knows the conversation is over.

Your first message-passing program:

If you see Got: hi printed, the spawned thread successfully sent a value, the main thread received it, and ownership was safely transferred. No locks were harmed in the process.

Ownership in Message Passing

Sending a value through a channel moves it. After tx.send(value), the sender cannot use value anymore. This is the mechanism that prevents two threads from accidentally accessing the same data simultaneously.

Try to use the value after sending it, and the compiler will stop you:

use std::sync::mpsc;
use std::thread;
fn main() {
    let (tx, rx) = mpsc::channel();
    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
        println!("val is {}", val); // error: borrow of moved value
    });
    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}

The error message is clear: val was moved into send, so it cannot be borrowed afterward. The compiler prevents the classic bug where one thread continues to read or modify data that was handed off to another thread.

Use-after-move is a compile-time error:

In many languages, accidentally using data after sending it to another thread would be a runtime bug — often intermittent and dependent on thread scheduling. Rust catches it before the code ever runs. This is what “fearless concurrency” means in practice.

Receiving Messages — Blocking vs Non-Blocking

The receiver gives you two ways to pick up messages: recv and try_recv.

  • recv() blocks the current thread until a message arrives or all senders are dropped. This is appropriate when the receiver has nothing else to do and must wait for work.
  • try_recv() does not block. It returns immediately: Ok(value) if a message was waiting, or Err if no message is currently available. This is useful when the receiver has other tasks it can perform while waiting, and periodically checks for new messages.
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
    let (tx, rx) = mpsc::channel();
    thread::spawn(move || {
        thread::sleep(Duration::from_millis(100));
        tx.send("later".to_string()).unwrap();
    });
    // Immediate check: no message yet.
    match rx.try_recv() {
        Ok(msg) => println!("Got immediately: {}", msg),
        Err(_)   => println!("Nothing yet, will wait..."),
    }
    // Block until the message arrives.
    let received = rx.recv().unwrap();
    println!("Got after waiting: {}", received);
}

If you run this, you will see Nothing yet, will wait... printed first, then a pause, and finally Got after waiting: later. The main thread tried to grab a message without blocking, saw none, and then fell back to a blocking recv that waited until the spawned thread sent its value.

Sending Multiple Values and Iterating

A channel is not limited to a single message. One thread can send a stream of values, and the receiver can treat the channel as an iterator. This produces a clean, for-each style consumption.

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("first"),
            String::from("second"),
            String::from("third"),
        ];
        for msg in messages {
            tx.send(msg).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });
    // The for loop will keep pulling values until the channel is closed.
    for received in rx {
        println!("Got: {}", received);
    }
}

The main thread prints each message as it arrives, with a one-second pause between lines. The for loop over rx automatically terminates when all senders are dropped — in this case, when the spawned thread finishes sending and its tx goes out of scope. You do not need a manual sentinel value or an explicit stop signal.

Blocking on iterator:

Iterating over the receiver blocks the thread if no messages are currently available, exactly like calling recv() repeatedly. If you need to do other work while waiting, you should call try_recv() in a loop with a timeout or use a non-blocking approach. A for loop over rx is appropriate only when the thread is dedicated entirely to consuming messages.

Multiple Producers — Cloning the Sender

A single receiver can gather work from many threads. You achieve this by cloning the transmitter. Each clone can be moved into a different spawned thread.

use std::sync::mpsc;
use std::thread;
fn main() {
    let (tx, rx) = mpsc::channel();
    for i in 0..4 {
        let tx_clone = tx.clone();
        thread::spawn(move || {
            tx_clone.send(i).unwrap();
        });
    }
    // Drop the original tx so the channel closes when all clones are done.
    drop(tx);
    for received in rx {
        println!("Got: {}", received);
    }
}

Each spawned thread gets its own clone of the transmitter and sends a number. The original tx is explicitly dropped so that the channel will close after all four clones have been dropped — otherwise the original tx would keep the channel open forever. The main thread iterates over rx and prints numbers as they arrive. The order of output is non-deterministic because the threads run concurrently.

If you omit drop(tx), the for loop will block indefinitely waiting for another message that will never come. The original tx is still alive in the main thread’s scope, so the channel never closes. Explicitly dropping the original sender when it is no longer needed is a common pattern.

Channel Closure and Graceful Shutdown

When all senders are dropped, the channel is closed. The receiver’s recv() returns an error (RecvError), and any for loop over the receiver stops. This behavior lets you use the lifetime of senders as a natural shutdown signal.

In many concurrent programs, the receiver does not know in advance how many messages will arrive. Instead, the senders simply go out of scope when their work is done, and the receiver detects the end of the stream.

A common mistake is to send an explicit “shutdown” message alongside a drop-based closure, which can lead to subtle ordering bugs. The article “Rust concurrency: the archetype of a message-passing bug” describes a real-world failure where a workflow expected an error message to arrive before a shutdown signal, but because the signals came from two different threads, the order was unpredictable.

Ordering across multiple senders is not guaranteed:

When you have multiple threads sending messages to the same receiver, the order in which those messages arrive depends on thread scheduling. You cannot assume that a message sent by thread A before thread B sends its own message will be received first. The only ordering guarantee is that messages from the same sender are received in the order they were sent. If your logic requires a specific sequence across threads, you must design the protocol to tolerate any interleaving, or use a single sender to serialize the messages.

A robust shutdown pattern is to avoid explicit shutdown messages entirely. Instead, let the channel closure itself signal completion: the receiver processes messages as long as they arrive and stops when the senders are dropped. If a sender needs to notify the receiver of an error before shutting down, it should send the error message and then let itself be dropped — the receiver will eventually see the error, then the channel will close when all senders have finished.

When to Use Message Passing vs Shared State

Message passing shines when the data flows in one direction and each piece of data is owned by a single thread at a time. Worker threads producing results, event-driven architectures, and pipelines of processing stages are all natural fits.

Shared-state concurrency is more appropriate when multiple threads genuinely need to read and modify the same data concurrently — a shared cache, a global counter, or a data structure that threads cooperatively build. Rust supports both models, and the type system ensures that whichever you choose, the compiler will check the safety guarantees for you.

You pick the right tool for the problem:

Rust does not force one concurrency model. You can use channels when ownership transfer makes the design simpler, and fall back to locks and atomics when shared access is necessary. The compiler’s ownership and type checks apply regardless, so you get compile-time guarantees in both cases.

Summary

Message passing via mpsc channels provides a clean, compile-time-enforced way to transfer data between threads. The ownership system ensures that once a value is sent, the sender cannot accidentally use it again. A channel can have many producers cloned from a single transmitter, feeding a single consumer that iterates over incoming messages. When all senders are dropped, the channel closes and the receiver stops — a natural shutdown mechanism.

The biggest trap is assuming that messages from multiple senders will arrive in a specific order. They will not. The only ordering guarantee is that one sender’s messages arrive in sequence. Design your protocols to either tolerate any interleaving or use a single sender to serialize critical sequences.