Skip to main content

Message Passing: Channels for Thread Communication

Message passing is one of Rust's safest approaches to concurrent programming. Instead of sharing memory between threads, channels allow threads to communicate by sending data from one thread to another. This is implemented through channels, which have a transmitter (sender) and receiver. Rust's ownership system ensures that once you send a value down a channel, you can't use it in the sending thread anymore, preventing data races by design. This guide covers creating channels, sending multiple messages, and handling multiple producers.

Key Takeaways

  • Channels are the safe way to communicate between threads; data ownership is transferred from sender to receiver
  • mpsc::channel() creates a multi-producer, single-consumer channel—one receiver, multiple senders possible
  • Transmitter (tx) sends values; receiver (rx) blocks until a message arrives
  • Ownership is transferred: When you send a value, the sending thread loses ownership; the receiver owns it
  • The receiver can iterate over messages with a for loop, which stops when the channel closes

What Is Message Passing?

Message passing is a technique for communicating between threads where one thread sends data to another. Instead of sharing memory (which requires locks and careful synchronization), each thread operates independently, receiving data through a channel.

In Rust, this is achieved using channels. A channel has two halves:

  • Transmitter (tx): The sending end; used to send data
  • Receiver (rx): The receiving end; waits for and processes incoming data

The key insight is ownership transfer: when you send a value down a channel, the sending thread loses ownership. This prevents the class of bugs where two threads could modify the same data simultaneously.

How Do You Create and Use a Channel?

Let's start with a simple example of creating a channel and sending a single message:

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();
});

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

Breaking down the code:

  1. use std::sync::mpsc; imports the mpsc module, which stands for "multiple producer, single consumer".
  2. mpsc::channel() creates and returns a tuple: (tx, rx). The transmitter is used for sending; the receiver is used for receiving.
  3. thread::spawn(move || { }) creates a new thread with a closure that captures tx via move (ownership is transferred).
  4. tx.send(val).unwrap() sends the value down the channel. The send() method returns a ResultOk(()) if successful, Err if the receiver has been dropped.
  5. rx.recv().unwrap() blocks the main thread until a message is received. The recv() method returns a Result<T, RecvError>.

The program prints: Got: hi.

How Do You Send Multiple Messages?

You can send multiple messages from a single producer. The receiver can iterate over messages using a for loop, which automatically stops when the channel closes:

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_secs(1));
}
});

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

How it works:

  1. The spawned thread sends four messages, sleeping one second between each.
  2. The main thread iterates over rx with a for loop.
  3. Each iteration prints a received message.
  4. When the transmitter is dropped (thread finishes), the receiver's for loop automatically terminates.

Output:

Got: hi
Got: from
Got: the
Got: thread

Why Does the Iteration Stop?

The for loop over a receiver automatically terminates when the channel is closed. The channel closes when:

  • All transmitter handles are dropped, or
  • The receiver explicitly calls drop(rx)

This design prevents deadlocks: if the receiver is iterating and no transmitter is left, the loop knows to stop instead of waiting forever.

How Do You Create Multiple Producers?

The "multiple producer" part of mpsc comes from cloning the transmitter. You can clone tx to create multiple independent senders:

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

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

// Clone the transmitter for the second thread
let tx1 = tx.clone();

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

for val in vals {
tx1.send(val).unwrap();
thread::sleep(Duration::from_millis(500));
}
});

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

for val in vals {
tx.send(val).unwrap();
thread::sleep(Duration::from_millis(700));
}
});

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

Key points:

  1. tx1 = tx.clone() creates a second transmitter that sends down the same channel.
  2. Each thread independently sends messages at different rates.
  3. The main thread receives messages in the order they arrive (interleaved based on timing).
  4. The receiver continues until all transmitters are dropped (both threads finish).

Possible output (interleaved):

Got: hi
Got: more
Got: from
Got: messages
Got: from
Got: thread
Got: one
Got: from
Got: thread two

The exact order depends on thread scheduling, which is why concurrent programs should not rely on specific message ordering unless explicitly synchronized.

Channels vs. Other Concurrency Patterns

Message passing suits different use cases than shared state:

ApproachUse CaseSafety
Channels (message passing)Independent workers passing resultsType-safe; ownership prevents data races
Mutex + ArcShared state that must be modifiedRuntime checks; deadlock possible with nested locks
Atomic typesSimple shared counters or flagsWait-free; limited to primitive types

Rust's philosophy: prefer message passing for concurrency. Use shared state only when message passing is impractical.

Best Practices for Channels

Idiomatic Rust:

  • Do this: Use channels for thread communication; let ownership transfer enforce safety.
  • Do this: Iterate with for loops over receivers; they're ergonomic and handle cleanup.
  • Do this: Clone transmitters explicitly; each clone is a potential sender.
  • Don't do this: Create channels for every two threads if one shared channel works.
  • Don't do this: Ignore send() or recv() errors in production code; handle them gracefully.

What Happens If the Receiver Is Dropped?

If the receiver is dropped (or consumed), subsequent send() calls will return an error:

use std::sync::mpsc;

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

drop(rx); // Explicitly drop the receiver

// This fails: the receiver is gone
match tx.send("hello".to_string()) {
Ok(()) => println!("Sent successfully"),
Err(_) => println!("Receiver has been dropped"),
}
}

This is a safety feature: you can't send to a receiver that doesn't exist.

Frequently Asked Questions

Can I send any type of value through a channel?

Yes, any type that implements Send (which is almost all types in Rust). The type is determined at channel creation: mpsc::channel::<MyType>(). The generic type is inferred from your first send() call if not specified.

What is the difference between mpsc and spmc?

mpsc (multiple producer, single consumer) is the standard. spmc (single producer, multiple consumer) is less common and requires a third-party crate like crossbeam. Rust's standard library only provides mpsc.

Can I create a bounded channel?

Yes, using mpsc::channel() actually creates an unbounded channel. If you want to limit the queue size, use mpsc::sync_channel(capacity):

let (tx, rx) = mpsc::sync_channel(1);  // Buffer only 1 message

The sender will block if the buffer is full until the receiver consumes messages.

What if the thread panics while holding the transmitter?

The transmitter is dropped when the thread panics, closing the channel. The receiver will get no more messages and its for loop will terminate. This is safe—panicking threads cleanly shut down their communications.

How do I handle errors from send() and recv()?

send() returns Result<(), SendError<T>> if the receiver is dropped. recv() returns Result<T, RecvError> if all transmitters are dropped:

match tx.send(val) {
Ok(()) => println!("Sent"),
Err(_) => println!("Receiver disconnected"),
}

match rx.recv() {
Ok(msg) => println!("Got: {}", msg),
Err(_) => println!("Channel closed"),
}

In production, always handle these errors instead of using unwrap().

Further Reading