Skip to content
AgileLogics
6 min read

Threads & Message Passing

Prerequisites: Smart pointers: Box, Rc & RefCell

A thread runs code at the same time as the rest of your program. Rust uses real operating-system threads from std::thread. The ownership rules you already know are what make Rust threads safe: the compiler rejects programs where two threads could access the same data unsafely ("data races").

Spawning and joining

thread::spawn takes a closure and runs it on a new thread. It returns a JoinHandle; calling .join() waits for the thread to finish and returns Ok(value) with whatever the closure returned, or Err if the thread panicked.

The closure usually needs move, because the new thread might outlive the function that spawned it, so it must own everything it uses.

When the main thread returns, the whole program exits — even if spawned threads are still running. Join the handles you care about.

Syntax

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

fn main() {
    let data = vec![1, 2, 3];
    let handle = thread::spawn(move || data.iter().sum::<i32>()); // `data` moves into the thread
    let total = handle.join().unwrap();                            // wait and collect the result

    let (tx, rx) = mpsc::channel();                                // sender, receiver
    thread::spawn(move || tx.send("hello").unwrap());
    let msg = rx.recv().unwrap();                                  // blocks until a value arrives

    println!("{total} {msg}");
}

Message passing with channels

mpsc stands for "multiple producer, single consumer". A channel has a Sender (clone it to get more producers) and one Receiver. Sending a value moves it into the channel, so the sending thread can't touch it afterwards. Iterating over the receiver (for msg in rx) yields values until every sender has been dropped.

Scoped threads

thread::scope creates threads that are guaranteed to finish before the scope ends. Because of that guarantee they may borrow local variables, with no move and no cloning.

Example

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

fn main() {
    // 1. spawn + join: each thread returns a value.
    let handles: Vec<_> = (1..=3)
        .map(|id| {
            thread::spawn(move || {
                thread::sleep(Duration::from_millis(10 * (4 - id)));
                id * id
            })
        })
        .collect();

    // Joining in order gives results in order, whichever thread finished first.
    let squares: Vec<u64> = handles.into_iter().map(|h| h.join().unwrap()).collect();
    println!("squares: {squares:?}");

    // 2. A channel with several producers.
    let (tx, rx) = mpsc::channel();
    for worker in 0..3 {
        let tx = tx.clone();
        thread::spawn(move || {
            for job in 0..2 {
                tx.send(format!("worker {worker} finished job {job}")).unwrap();
            }
        });
    }
    drop(tx); // drop the original sender so the loop below can end

    let mut messages: Vec<String> = rx.iter().collect();
    messages.sort(); // arrival order varies between runs; sort for stable output
    for m in &messages {
        println!("{m}");
    }

    // 3. Scoped threads borrow local data directly.
    let words = vec!["alpha", "beta", "gamma", "delta"];
    let (left, right) = words.split_at(2);
    let (a, b) = thread::scope(|s| {
        let h1 = s.spawn(|| left.iter().map(|w| w.len()).sum::<usize>());
        let h2 = s.spawn(|| right.iter().map(|w| w.len()).sum::<usize>());
        (h1.join().unwrap(), h2.join().unwrap())
    });
    println!("letters: {a} + {b} = {}", a + b);
    println!("words still usable: {}", words.len());
}

Expected output

squares: [1, 4, 9]
worker 0 finished job 0
worker 0 finished job 1
worker 1 finished job 0
worker 1 finished job 1
worker 2 finished job 0
worker 2 finished job 1
letters: 9 + 10 = 19
words still usable: 4

The threads really do run in an unpredictable order. The output is stable only because results are collected through join in a fixed order and the channel messages are sorted before printing. Printing directly from inside each thread would interleave differently from run to run.

Common mistakes

  • Forgetting move when the closure uses a local variable:

    use std::thread;
    
    fn main() {
        let v = vec![1, 2, 3];
        let handle = thread::spawn(|| println!("{v:?}"));
        handle.join().unwrap();
    }
    

    This fails with error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function. Add move, or use thread::scope if you want to borrow.

  • Not joining. If main returns first, unfinished threads are stopped mid-work and their output may never appear.

  • Keeping the original Sender alive. for msg in rx only ends once all senders are dropped; forgetting drop(tx) makes the program hang.

  • Using a value after sending it: tx.send(s).unwrap(); println!("{s}"); fails with error[E0382]: borrow of moved value: `s` .

  • Assuming output order. Code that prints from several threads, or iterates results in arrival order, gives different output on different runs.

Practice

Split the numbers 1 to 100 into four chunks of 25. Sum each chunk on its own thread with thread::spawn, collect the partial sums in chunk order with join, and print them and the grand total.

Show solution
use std::thread;

fn main() {
    let numbers: Vec<u64> = (1..=100).collect();

    let handles: Vec<_> = numbers
        .chunks(25)
        .map(|chunk| {
            let chunk = chunk.to_vec(); // each thread gets its own copy
            thread::spawn(move || chunk.iter().sum::<u64>())
        })
        .collect();

    let partials: Vec<u64> = handles.into_iter().map(|h| h.join().unwrap()).collect();
    let total: u64 = partials.iter().sum();
    println!("partial sums: {partials:?}");
    println!("total: {total}");
}
partial sums: [325, 950, 1575, 2200]
total: 5050