How to Share Data Between Threads in Rust

Share data between Rust threads safely using std::sync::mpsc channels to move ownership.

When data needs to move, not share

You're building a batch processor. The main thread scans a directory for image files. Worker threads resize those images. You need to pass filenames from the scanner to the workers. A shared vector requires a mutex for every single file, adding locking overhead to a simple handoff. A global static is a nightmare to initialize and clean up. Rust offers a cleaner path for one-way data flow: channels.

Channels model data movement. They transfer ownership from one thread to another, guaranteeing that only one thread holds the data at any moment. This eliminates data races by construction. You can't have two threads mutating the same value because the value only exists in one place at a time.

std::sync::mpsc is the standard library channel type. The name stands for Multiple Producer, Single Consumer. The "m" and "s" refer to the sender and receiver ends. You can clone the sender to create multiple producers. You cannot clone the receiver. This asymmetry enforces a strict pipeline. Data flows in one direction.

Think of a pneumatic tube system in a bank. Tellers drop canisters into the tube. The canisters travel to a central vault. The vault operator retrieves them. Tellers can't grab canisters back. The vault can't push canisters back. The tube moves items forward. Channels work the same way. You shove data into the sender, and it travels to the receiver.

Minimal example

Here is the basic pattern. A thread sends a string. The main thread receives it.

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

fn main() {
    // Create the channel. tx is the sender, rx is the receiver.
    let (tx, rx) = mpsc::channel();

    // Spawn a thread. The move closure takes ownership of tx.
    // This is required because tx must cross the thread boundary.
    thread::spawn(move || {
        let message = String::from("hello from thread");
        
        // send() takes ownership of message.
        // message is moved into the channel buffer.
        // You cannot use message after this line.
        tx.send(message).unwrap();
    });

    // recv() blocks the current thread until a message arrives.
    // It returns Ok(value) or Err if the channel is closed.
    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}

What happens under the hood

The move keyword is required on the closure. The compiler demands that the thread owns everything it captures. move forces the closure to take ownership of tx. If you omit move, the compiler rejects the code because tx would be borrowed, and borrows cannot escape the thread's lifetime.

Inside the thread, tx.send(message) consumes message. The value is moved into the channel's internal buffer. You cannot use message after sending. If you try, the compiler emits E0382 (use of moved value). This is the safety guarantee. The sender gives up the value. The receiver becomes the sole owner.

The receiver calls recv(). This function blocks the current thread. It spins in the kernel or uses futexes until data arrives. When data arrives, recv() returns Ok(value). If all senders are dropped and the buffer is empty, recv() returns Err. This signals that no more messages will ever come.

Channels only accept types that implement the Send trait. Send is a marker trait. It tells the compiler the type can be transferred across thread boundaries safely. Most types are Send. Rc<T> is not Send. Rc uses non-atomic reference counting. If two threads hold an Rc, the counter can corrupt. Arc<T> is Send. Arc uses atomic operations. If you try to send an Rc, you get E0277 (trait bound not satisfied). The fix is usually swapping Rc for Arc.

Trust the Send bound. If the compiler says a type isn't Send, there is a reason. Sending it will cause undefined behavior.

Multiple producers and cloning

Real code often has multiple producers. tx implements Clone. You can clone it and send clones to different threads. Each clone acts as an independent sender. The receiver gets messages from all of them interleaved.

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

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

    // Spawn three producer threads.
    for id in 0..3 {
        // Clone the sender for this thread.
        // Each thread gets its own tx handle.
        let tx_clone = tx.clone();
        
        thread::spawn(move || {
            let payload = format!("Work from worker {}", id);
            tx_clone.send(payload).unwrap();
        });
    }

    // Drop the original sender.
    // This ensures the channel closes when all clones drop.
    drop(tx);

    // Iterate over received messages.
    // iter() yields Ok(value) for each message.
    // It stops when all senders are dropped.
    for msg in rx.iter() {
        println!("Received: {}", msg);
    }
}

Notice drop(tx). If you don't drop the original sender, the receiver thinks the channel is still open. rx.iter() will block forever waiting for a message that never comes because the original tx is still alive. The convention is to drop the original sender immediately after cloning, or structure your code so the original goes out of scope.

Treat the original sender as a control handle. Drop it when you're done spawning workers.

Blocking, polling, and iteration

recv() blocks. This is fine for simple pipelines. If you need to do other work while waiting, use try_recv(). It returns immediately.

try_recv() returns Ok(value) if a message is available. It returns Err(TryRecvError::Empty) if the channel is empty but open. It returns Err(TryRecvError::Disconnected) if all senders are dropped and the buffer is empty.

use std::sync::mpsc::TryRecvError;

// Inside a loop or event handler:
match rx.try_recv() {
    Ok(msg) => process(msg),
    Err(TryRecvError::Empty) => {
        // No message yet. Do other work.
    }
    Err(TryRecvError::Disconnected) => {
        // Channel closed. Exit loop.
    }
}

Use try_recv() when you need non-blocking checks. Use recv() when you can afford to wait. Use rx.iter() when you want to process all remaining messages until the channel closes.

Pitfalls and errors

Channels are powerful, but they have traps.

The biggest trap is unbounded growth. mpsc channels are unbounded. They allocate a new buffer for every message. If producers are faster than consumers, the channel grows until you run out of memory. There is no backpressure. The producer never blocks. The consumer falls behind. Memory explodes.

Monitor your heap when using mpsc. If your producer can outpace your consumer, you need a bounded channel. Crates like crossbeam-channel offer bounded channels that block producers when the buffer is full.

Another pitfall is ignoring errors. tx.send() returns a Result. If the receiver is dropped, send() returns Err. If you unwrap() blindly, your thread panics. In production code, handle the error. Log it or stop the worker.

match tx.send(payload) {
    Ok(()) => {},
    Err(_) => {
        // Receiver dropped. Stop sending.
        eprintln!("Channel closed. Stopping worker.");
        break;
    }
}

Channels are unbounded. Fast producers will fill your RAM before the consumer catches up. Watch your memory usage.

Decision matrix

Use mpsc channels when you have a clear producer-consumer flow and data moves in one direction. Use mpsc when you want zero locking overhead for the data itself, since ownership transfers instead of shared access. Use Arc<Mutex<T>> when multiple threads need to read and write the same data in place, or when you need random access to a shared collection. Use bounded channels from crates like crossbeam when you need backpressure to prevent memory exhaustion from fast producers. Use scoped threads when you need to borrow data from the current stack frame without cloning or boxing.

Where to go next