Skip to editor content
learningrust.orglesson 18 of 26

Concurrency in Rust

Concurrent programming -- where different parts of a program execute independently -- is essential for building responsive, high-performance software. Rust's ownership system gives it a unique advantage: many concurrency bugs that are runtime errors in other languages become compile-time errors in Rust. This concept is often called fearless concurrency.

Spawning Threads

Rust's standard library provides OS threads via std::thread::spawn. Each thread runs a closure and returns a JoinHandle that you use to wait for the thread to finish:

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..=5 {
            println!("  spawned thread: count {}", i);
            thread::sleep(Duration::from_millis(50));
        }
        42 // threads can return values
    });

    for i in 1..=3 {
        println!("main thread: count {}", i);
        thread::sleep(Duration::from_millis(80));
    }

    // Wait for the thread to finish and get its return value
    let result = handle.join().expect("Thread panicked");
    println!("Spawned thread returned: {}", result);
}

That .join() at the end is doing far more than collecting a return value, and the easiest way to see it is to take it away. In the program below main spawns a worker and then simply finishes. The worker is given half a second of work to do; main needs microseconds. Decide what the whole program prints before you run it.

Predict

main spawns a worker that sleeps 500ms and then prints two lines, but main never calls .join() — it prints its own line and returns. What does this program print?

use std::thread;
use std::time::Duration;

fn main() {
  // The handle is deliberately discarded: no .join() anywhere.
  thread::spawn(|| {
      thread::sleep(Duration::from_millis(500));
      println!("worker: finished the job");
      println!("worker: shutting down");
  });

  println!("main: work has been handed off");
  // main returns here.
}

Only main: work has been handed off prints. When main returns, the process exits, and that kills every other thread wherever it happens to be — the worker is destroyed mid-sleep, roughly 499.9ms before its first println! would have run. Rust keeps no register of outstanding threads to wait for, and this is not a compile error either: discarding a JoinHandle is legal and, under the default lints, entirely silent. That is what makes it a real trap — the compiler that catches so many concurrency mistakes cannot catch this one, because "main might finish first" is a timing property, not a type property. .join() is the fix: it blocks until the thread finishes and hands back its return value.

Moving Data Into Threads

Because threads can outlive the scope that created them, Rust requires you to move owned data into thread closures using the move keyword. This transfers ownership and prevents data races:

use std::thread;

fn main() {
    let names = vec![
        String::from("Alice"),
        String::from("Bob"),
        String::from("Charlie"),
    ];

    // move transfers ownership of names into the thread
    let handle = thread::spawn(move || {
        println!("Processing {} names in background thread:", names.len());
        for name in &names {
            println!("  Hello, {}!", name);
        }
        names.len() // we still own names inside the closure
    });

    // names is no longer accessible here -- it was moved
    // println!("{:?}", names); // This would not compile

    let count = handle.join().unwrap();
    println!("Processed {} names", count);
}

That move keyword is not new machinery — it is the ownership rule from Ownership & Borrowing applied to a closure. Before reading on, work out precisely what it changes about the closure, and what it would do to a plain i32.

Recall

Without scrolling up: in Ownership & Borrowing you learned that assigning a String moves it while assigning an i32 copies it, because i32 implements Copy. Now apply that to move. Given let total = 5i32; and let label = String::from('batch');, both used inside a thread::spawn(move || ...) closure — what is true after the spawn?

So move is one rule wearing a new hat: it forces the closure to capture by value instead of by reference, and the type then decides what that costs. A String or Vec is moved and the outer name is dead afterwards; an i32 is Copy, so capture-by-value duplicates it and the original stays usable. thread::spawn insists on move because its closure must be 'static — a borrow of a local variable would dangle the instant the spawning function returned, and the borrow checker rejects that at compile time rather than letting you discover it at three in the morning.

Message Passing with Channels

Channels let threads communicate by sending messages. Rust provides mpsc (multiple producer, single consumer) channels. The sender can be cloned to allow multiple threads to send to the same receiver:

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

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

    // Clone the sender for a second producer
    let tx2 = tx.clone();

    // Producer 1: sends numbers
    thread::spawn(move || {
        let values = vec![1, 2, 3, 4, 5];
        for val in values {
            tx.send(format!("number: {}", val)).unwrap();
            thread::sleep(Duration::from_millis(30));
        }
    });

    // Producer 2: sends letters
    thread::spawn(move || {
        let values = vec!['a', 'b', 'c'];
        for val in values {
            tx2.send(format!("letter: {}", val)).unwrap();
            thread::sleep(Duration::from_millis(50));
        }
    });

    // Consumer: receive all messages
    // The iterator ends when all senders are dropped
    let mut count = 0;
    for received in rx {
        println!("Got: {}", received);
        count += 1;
    }
    println!("Received {} total messages", count);
}

Shared State with Mutex

A Mutex (mutual exclusion) protects data so only one thread can access it at a time. You call .lock() to acquire access, which returns a MutexGuard that automatically releases the lock when dropped:

use std::sync::Mutex;

fn main() {
    let counter = Mutex::new(0);

    // Lock the mutex to access the data
    {
        let mut num = counter.lock().unwrap();
        *num += 1;
        println!("Counter after increment: {}", *num);
    } // MutexGuard is dropped here, releasing the lock

    // Lock again
    {
        let mut num = counter.lock().unwrap();
        *num += 10;
    }

    // into_inner consumes the mutex and returns the data
    println!("Final counter: {}", counter.into_inner().unwrap());
}

Sharing a Mutex Across Threads with Arc

A Mutex alone cannot be shared across threads because Rc is not thread-safe. Instead, use Arc (Atomically Reference Counted), which is a thread-safe version of Rc:

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for i in 0..5 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
            println!("Thread {} incremented counter to {}", i, *num);
        });
        handles.push(handle);
    }

    // Wait for all threads to finish
    for handle in handles {
        handle.join().unwrap();
    }

    println!("Final count: {}", *counter.lock().unwrap());
}

The pattern Arc<Mutex<T>> is the standard way to share mutable data across threads in Rust. Arc provides shared ownership, and Mutex ensures only one thread modifies the data at a time.

Note the two halves of that pattern do two different jobs, and swapping either one for a plausible-looking alternative breaks it. The program below reaches for the smart pointer a learner meets first — the one from single-threaded code — and the compiler stops it cold with an error that names a trait rather than a line of your logic. Commit a hypothesis about which type is wrong and why before you change anything.

Debug

Four threads each add their own subtotal into one shared counter, and main prints the total once they have all finished. It refuses to compile, and the error names a trait rather than anything you wrote. Work out which type is the wrong one and what property it is missing, then fix it so the program prints its total and 'All checks passed.'

use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
  let total = Rc::new(Mutex::new(0u32));
  let mut handles = Vec::new();

  // Four workers, each adding its own subtotal into the shared counter.
  for subtotal in [10u32, 20, 30, 40] {
      let total = Rc::clone(&total);
      let handle = thread::spawn(move || {
          let mut guard = total.lock().unwrap();
          *guard += subtotal;
      });
      handles.push(handle);
  }

  // Nothing is printed from inside the threads, so the output is
  // the same on every run no matter what order they finish in.
  for handle in handles {
      handle.join().unwrap();
  }

  let final_total = *total.lock().unwrap();
  println!("total = {}", final_total);

  assert_eq!(final_total, 100, "all four subtotals should land in the counter");
  println!("All checks passed.");
}

Expected output: total = 100 All checks passed.

The error is error[E0277]: Rc<Mutex<u32>> cannot be sent between threads safely, with the note that Send is not implemented for it, pointed at thread::spawn. The Mutex was never the problem — the pointer wrapping it was. Rc keeps its reference count in a plain non-atomic integer, so two threads cloning at the same moment can interleave the read-modify-write, lose an increment, and free the value while a pointer to it still exists. Instead of leaving that as a data race you find in production, the standard library declines to implement Send for Rc and the compiler rejects the program. The fix is use std::sync::Arc; with Arc::new and Arc::clone — the same API over an atomic counter, which is Send + Sync and costs slightly more per clone. That extra cost is exactly why Rc still exists and is still the right default in single-threaded code.

Parallel Data Processing

A practical example of using threads to speed up computation. Here we split work across multiple threads and collect results:

use std::sync::{Arc, Mutex};
use std::thread;

fn is_prime(n: u64) -> bool {
    if n < 2 { return false; }
    if n < 4 { return true; }
    if n % 2 == 0 || n % 3 == 0 { return false; }
    let mut i = 5;
    while i * i <= n {
        if n % i == 0 || n % (i + 2) == 0 { return false; }
        i += 6;
    }
    true
}

fn main() {
    let ranges: Vec<(u64, u64)> = vec![
        (2, 2500),
        (2500, 5000),
        (5000, 7500),
        (7500, 10000),
    ];

    let results = Arc::new(Mutex::new(Vec::new()));
    let mut handles = vec![];

    for (start, end) in ranges {
        let results = Arc::clone(&results);
        let handle = thread::spawn(move || {
            let primes: Vec<u64> = (start..end).filter(|&n| is_prime(n)).collect();
            let count = primes.len();
            println!("Found {} primes in range {}..{}", count, start, end);
            results.lock().unwrap().extend(primes);
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    let final_results = results.lock().unwrap();
    println!("\nTotal primes found below 10000: {}", final_results.len());
    println!("First 10: {:?}", &final_results[..10.min(final_results.len())]);
}

Send and Sync Traits

Rust uses two marker traits to enforce thread safety at compile time:

  • Send: A type can be transferred between threads. Most types are Send. Notable exception: Rc<T> is not Send.
  • Sync: A type can be referenced from multiple threads simultaneously. A type T is Sync if &T is Send. Notable exception: RefCell<T> is not Sync.

These traits are automatically implemented by the compiler. You rarely need to implement them yourself, but understanding them helps you reason about why certain code does or does not compile:

use std::sync::{Arc, Mutex};
use std::thread;

// This function accepts only types that are Send + 'static
fn process_in_background<T: Send + 'static + std::fmt::Debug>(data: T) {
    let handle = thread::spawn(move || {
        println!("Processing in background: {:?}", data);
    });
    handle.join().unwrap();
}

fn main() {
    // i32 is Send -- this works
    process_in_background(42);

    // String is Send -- this works
    process_in_background(String::from("hello from another thread"));

    // Vec is Send if its elements are Send -- this works
    process_in_background(vec![1, 2, 3, 4, 5]);

    // Arc<Mutex<T>> is Send + Sync -- the standard shared state pattern
    let shared = Arc::new(Mutex::new(vec![1, 2, 3]));
    let shared_clone = Arc::clone(&shared);

    let handle = thread::spawn(move || {
        shared_clone.lock().unwrap().push(4);
        println!("Modified from thread: {:?}", shared_clone.lock().unwrap());
    });

    handle.join().unwrap();
    println!("Final state: {:?}", shared.lock().unwrap());
}

Try It Yourself

Reading about join, move and channels is not the same as reaching for them under your own name. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out — one fans work out to threads and sums the results, one collects a value back from each thread, and one drains a channel fed by several producers. Run the starter as-is and it fails immediately, naming the first check that did not pass. Implement each until every check passes and it prints All checks passed.

Notice what none of the three functions does: print from inside a thread. Thread interleaving is not deterministic, so a program whose output depends on which thread reached its println! first has a different answer on every run. Every function here gathers its results first — by joining, or by draining a channel — sorts anything whose order came from the threads, and only then produces output. That is not a testing trick; it is how you write concurrent code whose behaviour you can actually reason about.

Build

Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down. None of these functions may print: all output comes from main, after the threads are finished.

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

// TODO 1: sum every number across all chunks, one thread per chunk.
//   Spawn a thread for each chunk that sums it, push the JoinHandle into a
//   Vec, then join every handle and add up what they return.
//   parallel_sum(vec![vec![1, 2], vec![3]]) -> 6
//   The closure must be a move closure: it has to own its chunk, because
//   the thread may outlive the loop iteration that created it.
fn parallel_sum(chunks: Vec<Vec<u32>>) -> u32 {
  let _ = chunks;
  0
}

// TODO 2: uppercase each name on its own thread, then return the results
//   SORTED. Join the handles to collect each thread's return value, then
//   sort the Vec — the threads finish in an unpredictable order, so the
//   sort is what makes the answer the same on every run.
//   shout_all(vec!["bea".into(), "al".into()]) -> ["AL", "BEA"]
fn shout_all(names: Vec<String>) -> Vec<String> {
  let _ = names;
  Vec::new()
}

// TODO 3: send every value down a channel from its own producer thread,
//   then receive them all and return them SORTED.
//   Clone tx once per producer and move the clone into that thread.
//   Then DROP the original tx before iterating rx: for v in rx ends only
//   when every sender has been dropped, so an original left alive here
//   hangs the program forever.
//   funnel(vec![3, 1, 2]) -> [1, 2, 3]
fn funnel(values: Vec<u32>) -> Vec<u32> {
  let (tx, rx) = mpsc::channel::<u32>();
  let _ = tx;
  let _ = rx;
  let _ = values;
  Vec::new()
}

// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
  let chunks = vec![vec![1, 2, 3], vec![10, 20], vec![100]];
  assert_eq!(parallel_sum(chunks), 136, "every chunk's numbers should be added in");
  assert_eq!(parallel_sum(vec![]), 0, "no chunks means no work and a total of 0");

  let names = vec!["bea".to_string(), "al".to_string(), "cy".to_string()];
  assert_eq!(
      shout_all(names),
      vec!["AL".to_string(), "BEA".to_string(), "CY".to_string()],
      "shout_all must sort: threads finish in an unpredictable order"
  );

  assert_eq!(funnel(vec![3, 1, 2]), vec![1, 2, 3], "every sent value should arrive");
  assert_eq!(funnel(vec![]), Vec::<u32>::new(), "no producers means an empty drain, not a hang");

  println!("All checks passed.");
  println!("Total: {}", parallel_sum(vec![vec![4, 5], vec![6]]));
  println!("Shouted: {:?}", shout_all(vec!["zed".to_string(), "ada".to_string()]));
  println!("Funnelled: {:?}", funnel(vec![9, 7, 8]));
}

Expected output: All checks passed. Total: 15 Shouted: ["ADA", "ZED"] Funnelled: [7, 8, 9]

Once it passes, try two variations and predict each before running:

  1. Join inside the spawn loop. In parallel_sum, replace the handle Vec with a single accumulator and join immediately: for chunk in chunks { total += thread::spawn(move || chunk.iter().sum::<u32>()).join().unwrap(); }. Predict which check breaks before running. None of them — it still prints All checks passed., and that is the point. join blocks, so each chunk now runs to completion before the next thread is even created: you have written sequential code with the overhead of spawning a thread per item and none of the benefit. Correctness and concurrency are separate properties, and no assert in this program can tell them apart. Spawn all the work first, join second.
  2. Remove the drop(tx). Delete that one line from funnel. Predict what happens before running. It does not fail a check and it does not error — it hangs. The for v in rx loop ends only when every sender has been dropped, and the original tx is still sitting in scope holding the channel open, so the receiver waits for a message that can never arrive. The Playground will eventually cut it off; a real service would just stop. Compare that with variation 1: both bugs are invisible to the assert battery, which is why understanding the shutdown rule matters more here than passing the test.

Key Takeaways

  • thread::spawn creates OS threads; use .join() to wait for them to finish
  • The move keyword transfers ownership of data into a thread closure
  • Channels (mpsc) provide safe message passing between threads
  • Mutex<T> protects shared data with mutual exclusion; .lock() acquires access
  • Arc<T> is a thread-safe reference-counted pointer for sharing ownership across threads
  • The Arc<Mutex<T>> pattern is the standard way to share mutable state between threads
  • Send and Sync are marker traits the compiler uses to enforce thread safety at compile time

Pro Tip: Prefer message passing (channels) over shared state (mutexes) when possible. Channels make data flow explicit and reduce the risk of deadlocks. When you do need shared state, keep the critical section (the code between .lock() and the guard being dropped) as short as possible.

Next Steps

To unlock more advanced memory patterns, we'll next explore smart pointers — Box, Rc, Arc, and RefCell — which give you fine-grained control over how data is stored and shared.

Next lesson

Smart Pointers

Understand Box, Rc, RefCell, and interior mutability patterns in Rust

30 min