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

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

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.

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

Build a multi-threaded word counter that processes text chunks in parallel. Try changing the number of chunks or the input text:

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

fn count_words(text: &str) -> HashMap<String, usize> {
    let mut counts = HashMap::new();
    for word in text.split_whitespace() {
        let word = word.to_lowercase();
        let word = word.trim_matches(|c: char| !c.is_alphanumeric());
        if !word.is_empty() {
            *counts.entry(word.to_string()).or_insert(0) += 1;
        }
    }
    counts
}

fn main() {
    let texts = vec![
        "the quick brown fox jumps over the lazy dog",
        "the dog barked at the fox and the fox ran away",
        "a quick red fox and a slow brown dog sat together",
        "the lazy dog slept while the brown fox jumped",
    ];

    let global_counts: Arc<Mutex<HashMap<String, usize>>> =
        Arc::new(Mutex::new(HashMap::new()));

    let mut handles = vec![];

    for (i, text) in texts.iter().enumerate() {
        let text = text.to_string();
        let global_counts = Arc::clone(&global_counts);

        let handle = thread::spawn(move || {
            let local_counts = count_words(&text);
            println!("Thread {} found {} unique words", i, local_counts.len());

            let mut global = global_counts.lock().unwrap();
            for (word, count) in local_counts {
                *global.entry(word).or_insert(0) += count;
            }
        });
        handles.push(handle);
    }

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

    let counts = global_counts.lock().unwrap();
    let mut sorted: Vec<(&String, &usize)> = counts.iter().collect();
    sorted.sort_by(|a, b| b.1.cmp(a.1));

    println!("\nTop 10 words:");
    for (word, count) in sorted.iter().take(10) {
        println!("  {:>8}: {}", word, count);
    }
}

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